support_desk 0.1.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/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- metadata +229 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# View helpers, available BOTH inside the engine's own views and in the
|
|
5
|
+
# HOST app's views (mixed into ActionView via the on_load hook at the
|
|
6
|
+
# bottom of this file — the same pattern chats and moderate use).
|
|
7
|
+
#
|
|
8
|
+
# Two of them are the public API (PRD §4.6):
|
|
9
|
+
#
|
|
10
|
+
# <%= link_to_support about: @ride %>
|
|
11
|
+
# <%= support_unread_badge %>
|
|
12
|
+
#
|
|
13
|
+
# The rest are what the bundled views render with, and they stay available
|
|
14
|
+
# after `rails g support_desk:views` so an ejected copy keeps working.
|
|
15
|
+
module EngineHelper
|
|
16
|
+
# Which frame the wizard renders on each of its three steps.
|
|
17
|
+
WIZARD_PARTIALS = { topic: "pick_topic", subject: "pick_thing", compose: "write" }.freeze
|
|
18
|
+
|
|
19
|
+
# The door: "¿Necesitas ayuda con esto?" next to a record, or "Contactar
|
|
20
|
+
# con soporte" on its own.
|
|
21
|
+
#
|
|
22
|
+
# <%= link_to_support about: @ride %>
|
|
23
|
+
# <%= link_to_support about: @withdrawal, text: "Reportar un problema", class: "btn" %>
|
|
24
|
+
# <%= link_to_support text: "Contactar con soporte" %>
|
|
25
|
+
#
|
|
26
|
+
# Renders NOTHING when there is no requester, when the record isn't
|
|
27
|
+
# supportable, or when it isn't this requester's to ask about — so it is
|
|
28
|
+
# safe to drop into a shared partial unconditionally. When they already
|
|
29
|
+
# have a case open about it, the door leads to that conversation instead
|
|
30
|
+
# of opening a second one.
|
|
31
|
+
def link_to_support(about: nil, text: nil, **html_options)
|
|
32
|
+
requester = support_desk_requester
|
|
33
|
+
return if requester.nil?
|
|
34
|
+
return if about && !support_subject_available?(about, requester)
|
|
35
|
+
|
|
36
|
+
ticket = about && open_support_ticket_about(about, requester)
|
|
37
|
+
if ticket
|
|
38
|
+
link_to text || t("support_desk.doors.existing"), support_thread_path(ticket), **html_options
|
|
39
|
+
else
|
|
40
|
+
link_to text || support_door_text(about), support_desk_routes.new_ticket_path(**support_door_params(about)),
|
|
41
|
+
**html_options
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The number for a nav item or a tab dock: unread messages across every
|
|
46
|
+
# support conversation this requester has. Renders nothing at zero, so
|
|
47
|
+
# an empty badge never sits in a layout.
|
|
48
|
+
def support_unread_badge(requester = support_desk_requester, css_class: "chats-badge")
|
|
49
|
+
return if requester.nil? || !requester.respond_to?(:unread_support_count)
|
|
50
|
+
|
|
51
|
+
count = requester.unread_support_count
|
|
52
|
+
return if count.zero?
|
|
53
|
+
|
|
54
|
+
tag.span(count > 99 ? "99+" : count, class: css_class)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# --- What the bundled views render with -----------------------------------
|
|
58
|
+
|
|
59
|
+
# The gem's bundled stylesheet, into the host layout's <head> — where a
|
|
60
|
+
# stylesheet belongs, where `data-turbo-track` means something, and where
|
|
61
|
+
# it loads before the page paints instead of after.
|
|
62
|
+
#
|
|
63
|
+
# Requires `<%= yield :head %>` in the host layout (every Rails app
|
|
64
|
+
# generated this decade has one; add it if yours doesn't). Rendering a
|
|
65
|
+
# <link> mid-body instead would be invalid markup, an inert turbo-track
|
|
66
|
+
# attribute, and a flash of unstyled support screen.
|
|
67
|
+
def support_desk_styles
|
|
68
|
+
content_for(:head) { stylesheet_link_tag "support_desk", "data-turbo-track": "reload" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# "Normalmente respondemos en menos de 24 h" for a desk, or nil when the
|
|
72
|
+
# desk promises nothing. The same `config.reply_within` the SLA breaches
|
|
73
|
+
# on — one setting, one truth.
|
|
74
|
+
def support_reply_promise(desk_key = :default)
|
|
75
|
+
within = SupportDesk.config.desk(desk_key).reply_within
|
|
76
|
+
return nil if within.nil?
|
|
77
|
+
|
|
78
|
+
t("support_desk.thread.promise", time: SupportDesk::Wizard.humanize_duration(within))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The status line under a case in the requester's list: who owes the next
|
|
82
|
+
# word, or that it's over.
|
|
83
|
+
def support_ticket_state(ticket)
|
|
84
|
+
return t("support_desk.tickets.state.closed") if ticket.closed?
|
|
85
|
+
|
|
86
|
+
ticket.awaiting_reply? ? t("support_desk.tickets.state.awaiting_reply") : t("support_desk.tickets.state.answered")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The partial for the step this wizard is on. The wizard decides which
|
|
90
|
+
# step that is; this only says where the template lives.
|
|
91
|
+
def support_wizard_partial(wizard)
|
|
92
|
+
"support_desk/tickets/#{WIZARD_PARTIALS.fetch(wizard.step)}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Whether the chats inbox should show the "¿Necesitas ayuda? Escríbenos"
|
|
96
|
+
# door for +viewer+: only under `inbox_entry: :always`, and only until
|
|
97
|
+
# they have a case, because from then on chats renders the desk's own
|
|
98
|
+
# grouped row and two entries would be one too many.
|
|
99
|
+
def support_inbox_door?(viewer)
|
|
100
|
+
return false unless viewer.respond_to?(:ask_support!)
|
|
101
|
+
|
|
102
|
+
key = viewer.class.try(:support_desk_key) || :default
|
|
103
|
+
return false unless SupportDesk.config.desk(key).inbox_entry == :always
|
|
104
|
+
|
|
105
|
+
!viewer.support_tickets.exists?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The desk's face. Once the desk has a row it is just another chats
|
|
109
|
+
# messager, so chats draws it; before that (nobody has ever written to
|
|
110
|
+
# it) an initials disc from `config.name`.
|
|
111
|
+
#
|
|
112
|
+
# It never CREATES the desk: this renders on an inbox that may have
|
|
113
|
+
# nothing to do with support, and a page view is not a reason to INSERT.
|
|
114
|
+
def support_desk_avatar(desk_key = :default, css_class: "chats-avatar")
|
|
115
|
+
desk = SupportDesk::Desk.find_by(key: desk_key.to_s)
|
|
116
|
+
return chats_messager_avatar(desk, css_class: css_class) if desk
|
|
117
|
+
|
|
118
|
+
name = SupportDesk.config.desk(desk_key).name
|
|
119
|
+
initials = name.to_s.split.first(2).filter_map { |word| word[0] }.join.upcase
|
|
120
|
+
tag.span(initials.presence || "?", class: "#{css_class} chats-avatar--initials", "aria-hidden": true)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Where a case is read and answered: its chats conversation. Falls back
|
|
124
|
+
# to the engine's own ticket URL (which redirects to the same place) for
|
|
125
|
+
# the one case that has no conversation yet, so a link is never dead.
|
|
126
|
+
def support_thread_path(ticket)
|
|
127
|
+
return support_desk_routes.ticket_path(ticket) if ticket.conversation.nil? || !respond_to?(:chats_routes)
|
|
128
|
+
|
|
129
|
+
chats_routes.conversation_path(ticket.conversation)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Engine URL helpers that work from EVERY render context: the mounted
|
|
133
|
+
# proxy (`support_desk.`) inside host views and broadcasts, and the
|
|
134
|
+
# engine's own helpers when the host hasn't mounted it under the default
|
|
135
|
+
# name. Same reasoning as chats' `chats_routes`.
|
|
136
|
+
def support_desk_routes
|
|
137
|
+
respond_to?(:support_desk) ? support_desk : SupportDesk::Engine.routes.url_helpers
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# The current requester in whatever context the helper runs (host page or
|
|
143
|
+
# engine view), resolved through the configured controller method.
|
|
144
|
+
def support_desk_requester
|
|
145
|
+
method_name = SupportDesk.config.current_requester_method
|
|
146
|
+
requester = respond_to?(method_name) ? send(method_name) : nil
|
|
147
|
+
requester if requester.respond_to?(:ask_support!)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The requester's open cases, keyed by what they are about, loaded ONCE
|
|
151
|
+
# per request however many doors the page draws. A list screen with a
|
|
152
|
+
# door on every card (CarHey's trip cards) would otherwise pay a query
|
|
153
|
+
# per card, which is the kind of N+1 that only shows up in production.
|
|
154
|
+
#
|
|
155
|
+
# Memoised on the view instance, which is the render's own scope — the
|
|
156
|
+
# same place chats caches its slot lookups.
|
|
157
|
+
def open_support_ticket_about(record, requester)
|
|
158
|
+
@support_open_tickets ||= SupportDesk::Ticket.not_closed
|
|
159
|
+
.where(requester: requester)
|
|
160
|
+
.where.not(subject_id: nil)
|
|
161
|
+
.index_by { |t| [ t.subject_type, t.subject_id.to_s ] }
|
|
162
|
+
|
|
163
|
+
@support_open_tickets[[ record.class.polymorphic_name, record.id.to_s ]]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Supportable, and theirs to ask about.
|
|
167
|
+
def support_subject_available?(record, requester)
|
|
168
|
+
return false unless record.respond_to?(:supportable?) && record.supportable?
|
|
169
|
+
|
|
170
|
+
record.supportable_by?(requester)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def support_door_text(about)
|
|
174
|
+
return t("support_desk.doors.new") if about.nil?
|
|
175
|
+
|
|
176
|
+
t("support_desk.doors.about", subject: about.support_label)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def support_door_params(about)
|
|
180
|
+
return {} if about.nil?
|
|
181
|
+
|
|
182
|
+
{ about: SupportDesk::Wizard.sign_subject(about) }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Expose the helpers to the HOST app's views (isolated engines don't share
|
|
188
|
+
# helpers automatically). The hook lives HERE, at the bottom of the file that
|
|
189
|
+
# defines the constant — not in an engine initializer — so it is
|
|
190
|
+
# self-resolving: whenever this file loads, the constant already exists by
|
|
191
|
+
# the time the hook can run. See the same note in Chats::EngineHelper.
|
|
192
|
+
if defined?(ActiveSupport)
|
|
193
|
+
ActiveSupport.on_load(:action_view) do
|
|
194
|
+
include SupportDesk::EngineHelper
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<%# chats' `inbox_top` slot, shipped by this engine (seam S5).
|
|
2
|
+
|
|
3
|
+
The desk's own inbox row comes from chats, from `acts_as_messager inbox:
|
|
4
|
+
:grouped` — this is NOT that row. This is the door that stands in its
|
|
5
|
+
place BEFORE the requester has ever written: a support entry that only
|
|
6
|
+
appears once you already have a ticket is undiscoverable, which is the
|
|
7
|
+
whole reason `config.inbox_entry` defaults to :always.
|
|
8
|
+
|
|
9
|
+
Locals come from chats' inbox (viewer:, inbox:). Renders nothing under
|
|
10
|
+
:when_tickets or :never, and nothing once there is a real row to show. %>
|
|
11
|
+
<% if support_inbox_door?(viewer) %>
|
|
12
|
+
<%= support_desk_styles %>
|
|
13
|
+
<ul class="chats-inbox__list support-desk-inbox-door">
|
|
14
|
+
<li class="chats-row">
|
|
15
|
+
<%= link_to support_desk_routes.root_path, class: "chats-row__link" do %>
|
|
16
|
+
<%= support_desk_avatar(viewer.class.try(:support_desk_key) || :default) %>
|
|
17
|
+
|
|
18
|
+
<span class="chats-row__body">
|
|
19
|
+
<span class="chats-row__top">
|
|
20
|
+
<span class="chats-row__title">
|
|
21
|
+
<%= SupportDesk.config.desk(viewer.class.try(:support_desk_key) || :default).name %>
|
|
22
|
+
</span>
|
|
23
|
+
</span>
|
|
24
|
+
<span class="chats-row__bottom">
|
|
25
|
+
<span class="chats-row__preview"><%= t("support_desk.inbox.empty_hint") %></span>
|
|
26
|
+
</span>
|
|
27
|
+
</span>
|
|
28
|
+
<% end %>
|
|
29
|
+
</li>
|
|
30
|
+
</ul>
|
|
31
|
+
<% end %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# chats' `locked_composer` slot (seam S5). It replaces the BODY of the
|
|
2
|
+
locked composer, so it renders the notice itself in both branches — a
|
|
3
|
+
conversation locked by something that isn't a ticket must look exactly
|
|
4
|
+
like chats' own default.
|
|
5
|
+
|
|
6
|
+
Only a desk configured `closed_tickets: :locked` ever gets here: the
|
|
7
|
+
default (:reopen_on_reply) leaves the composer in place and writing
|
|
8
|
+
reopens the case. When it IS locked, the way forward is a new
|
|
9
|
+
conversation, prefilled with the same topic, because a closed door with
|
|
10
|
+
no other door next to it is where support tickets go to die. %>
|
|
11
|
+
<p class="chats-composer__locked-notice"><%= notice %></p>
|
|
12
|
+
|
|
13
|
+
<% if (ticket = SupportDesk::Ticket.for_conversation(conversation)) %>
|
|
14
|
+
<%= support_desk_styles %>
|
|
15
|
+
<%= link_to t("support_desk.doors.new_conversation"),
|
|
16
|
+
support_desk_routes.new_ticket_path(topic: ticket.topic&.path),
|
|
17
|
+
class: "support-desk-button" %>
|
|
18
|
+
<% end %>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<%# Close, reopen, and refile — the header buttons.
|
|
2
|
+
|
|
3
|
+
Nothing here decides what an agent may do: `ticket.actions_for(agent)`
|
|
4
|
+
already filtered the list by policy, status and duty, so this file
|
|
5
|
+
renders exactly what it was handed. Adding a button that isn't in
|
|
6
|
+
`actions` would render something the server refuses, which is how
|
|
7
|
+
consoles end up teaching people to ignore errors. %>
|
|
8
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
9
|
+
<% if actions.include?(:change_topic) %>
|
|
10
|
+
<%= form_with url: console_ticket_path(ticket, :change_topic), method: :post, class: "flex items-center gap-2" do %>
|
|
11
|
+
<%= select_tag :topic,
|
|
12
|
+
options_for_select(ticket.desk_config.topics.leaves.map { |topic| [ topic.full_label, topic.path ] },
|
|
13
|
+
ticket.topic&.path),
|
|
14
|
+
class: "rounded-md border border-slate-300 px-2 py-1.5 text-sm" %>
|
|
15
|
+
<%= submit_tag t("support_desk.console.actions.change_topic"),
|
|
16
|
+
class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<% if actions.include?(:close) %>
|
|
21
|
+
<%= button_to t("support_desk.console.actions.close"), console_ticket_path(ticket, :close), method: :post,
|
|
22
|
+
class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% if actions.include?(:reopen) %>
|
|
26
|
+
<%= button_to t("support_desk.console.actions.reopen"), console_ticket_path(ticket, :reopen), method: :post,
|
|
27
|
+
class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<%# Who holds this case, and how it changes hands.
|
|
2
|
+
|
|
3
|
+
The console offers the same two moves whatever the reply policy is, on
|
|
4
|
+
purpose: "Tomar" for an agent who is going to answer now, and "Pasar a…"
|
|
5
|
+
for the one who is handing over. A hand-off carries a note, and that
|
|
6
|
+
note is always internal — the requester is told who is looking after
|
|
7
|
+
them (or not) by `config.announce_assignments`, never by reading a
|
|
8
|
+
staffing message.
|
|
9
|
+
|
|
10
|
+
The picker lists the desk's ON-DUTY agents, which is also the only set
|
|
11
|
+
the server will accept: an agent_id that isn't in the pool comes back as
|
|
12
|
+
a flash, not an assignment. %>
|
|
13
|
+
<section class="rounded-lg border border-slate-200 bg-white p-4">
|
|
14
|
+
<h2 class="text-sm font-semibold"><%= t("support_desk.console.assignment.title") %></h2>
|
|
15
|
+
|
|
16
|
+
<p class="mt-2 text-sm text-slate-600">
|
|
17
|
+
<% if ticket.assigned? %>
|
|
18
|
+
<%= t("support_desk.console.assignment.held_by", agent: ticket.assignee.support_agent_name) %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<%= t("support_desk.console.assignment.unassigned") %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<%# Everyone who could take it over, which is the desk's on-duty pool minus
|
|
25
|
+
two people: whoever already has it, and you. "Assign to myself" is what
|
|
26
|
+
Take is, and offering it twice in one panel is how a picker starts
|
|
27
|
+
lying about what it does. %>
|
|
28
|
+
<% agents = ticket.desk.on_duty_agents.reject { |agent| agent == current_agent || ticket.assigned_to?(agent) } %>
|
|
29
|
+
|
|
30
|
+
<div class="mt-3 space-y-3">
|
|
31
|
+
<% if actions.include?(:assign) && !ticket.assigned_to?(current_agent) %>
|
|
32
|
+
<%= button_to t("support_desk.console.actions.take"), console_ticket_path(ticket, :take), method: :post,
|
|
33
|
+
class: "w-full rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<% if agents.any? && (actions.include?(:hand_off) || actions.include?(:assign)) %>
|
|
37
|
+
<% handing_off = actions.include?(:hand_off) %>
|
|
38
|
+
<%= form_with url: console_ticket_path(ticket, handing_off ? :hand_off : :assign), method: :post, class: "space-y-2" do %>
|
|
39
|
+
<%= select_tag :agent_id,
|
|
40
|
+
options_from_collection_for_select(agents, :id, :support_agent_name),
|
|
41
|
+
include_blank: t("support_desk.console.assignment.choose_agent"),
|
|
42
|
+
required: true,
|
|
43
|
+
class: "w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
44
|
+
|
|
45
|
+
<% if handing_off %>
|
|
46
|
+
<%= text_field_tag :note, nil, placeholder: t("support_desk.console.assignment.note_placeholder"),
|
|
47
|
+
class: "w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
48
|
+
<% end %>
|
|
49
|
+
|
|
50
|
+
<%= submit_tag t("support_desk.console.actions.#{handing_off ? "hand_off" : "assign"}"),
|
|
51
|
+
class: "w-full rounded-md border border-slate-300 px-3 py-2 text-sm font-medium hover:bg-slate-50" %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<% if actions.include?(:release) %>
|
|
56
|
+
<%= button_to t("support_desk.console.actions.release"), console_ticket_path(ticket, :release), method: :post,
|
|
57
|
+
class: "w-full rounded-md border border-slate-300 px-3 py-2 text-sm font-medium hover:bg-slate-50" %>
|
|
58
|
+
<% end %>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<%# Two ways to write, and they are not the same thing: a REPLY goes to the
|
|
2
|
+
requester and moves the SLA clock, a NOTE never leaves the desk. Mixing
|
|
3
|
+
them up is the classic support-tool accident, so they are two tabs and
|
|
4
|
+
two buttons, never one box with a checkbox.
|
|
5
|
+
|
|
6
|
+
The tab lives in the URL (`?compose=note`) rather than in a Stimulus
|
|
7
|
+
controller: no JavaScript to ship, a state you can link to, and a
|
|
8
|
+
composer that still works on the day the bundle fails to build. %>
|
|
9
|
+
<% composing_note = params[:compose].to_s == "note" %>
|
|
10
|
+
<% may_reply = actions.include?(:reply) %>
|
|
11
|
+
|
|
12
|
+
<section class="rounded-lg border border-slate-200 bg-white">
|
|
13
|
+
<nav class="flex gap-1 border-b border-slate-200 px-2 pt-2">
|
|
14
|
+
<% [ [ :reply, !composing_note ], [ :note, composing_note ] ].each do |tab, current| %>
|
|
15
|
+
<%= link_to console_ticket_path(ticket, :show, compose: tab),
|
|
16
|
+
"aria-current": ("page" if current),
|
|
17
|
+
class: "rounded-t-md px-3 py-2 text-sm font-medium #{current ? "bg-slate-900 text-white" : "text-slate-500 hover:text-slate-900"}" do %>
|
|
18
|
+
<%= t("support_desk.console.composer.#{tab}") %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</nav>
|
|
22
|
+
|
|
23
|
+
<div class="p-4">
|
|
24
|
+
<% if composing_note %>
|
|
25
|
+
<%= form_with url: console_ticket_path(ticket, :note), method: :post do %>
|
|
26
|
+
<%= text_area_tag :body, nil, rows: 3, required: true,
|
|
27
|
+
placeholder: t("support_desk.console.composer.note_placeholder"),
|
|
28
|
+
class: "w-full rounded-md border border-amber-300 bg-amber-50 p-2 text-sm text-slate-900" %>
|
|
29
|
+
<div class="mt-2 flex items-center justify-between gap-3">
|
|
30
|
+
<p class="text-xs text-slate-500"><%= t("support_desk.console.composer.note_hint") %></p>
|
|
31
|
+
<%= submit_tag t("support_desk.console.actions.note"),
|
|
32
|
+
class: "rounded-md bg-amber-500 px-3 py-2 text-sm font-medium text-white hover:bg-amber-600" %>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<% elsif may_reply %>
|
|
37
|
+
<%# multipart is explicit because `form_with` only infers it from the
|
|
38
|
+
FORM BUILDER's file_field, and this is a file_field_tag — without
|
|
39
|
+
it the browser posts the filename as a plain String and the
|
|
40
|
+
attachment silently never happens. %>
|
|
41
|
+
<%= form_with url: console_ticket_path(ticket, :reply), method: :post, html: { multipart: true } do %>
|
|
42
|
+
<%= text_area_tag :body, nil, rows: 3,
|
|
43
|
+
placeholder: t("support_desk.console.composer.reply_placeholder"),
|
|
44
|
+
class: "w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
45
|
+
<div class="mt-2 flex flex-wrap items-center justify-between gap-3">
|
|
46
|
+
<%= file_field_tag "files[]", multiple: true, class: "text-xs text-slate-500" %>
|
|
47
|
+
<%= submit_tag t("support_desk.console.actions.reply"),
|
|
48
|
+
class: "rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<% else %>
|
|
53
|
+
<%# `actions_for` already decided this agent may not answer — because
|
|
54
|
+
the desk is :assignee_only and somebody else holds the case, or
|
|
55
|
+
because it is closed. Say which, and offer the way out rather than
|
|
56
|
+
a box that would only 422. %>
|
|
57
|
+
<p class="text-sm text-slate-500">
|
|
58
|
+
<% if ticket.closed? %>
|
|
59
|
+
<%= t("support_desk.console.composer.closed") %>
|
|
60
|
+
<% elsif ticket.assigned? %>
|
|
61
|
+
<%= t("support_desk.console.composer.assignee_only", agent: ticket.assignee.support_agent_name) %>
|
|
62
|
+
<% else %>
|
|
63
|
+
<%# Nobody holds it, so there is no name to print — saying "is
|
|
64
|
+
handling this case" with a blank in front of it is worse than
|
|
65
|
+
saying nothing. Name the way out instead. %>
|
|
66
|
+
<%= t("support_desk.console.errors.take_it_first") %>
|
|
67
|
+
<% end %>
|
|
68
|
+
</p>
|
|
69
|
+
<% end %>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<%# What the case is about, and what state that thing is in — the answer to
|
|
2
|
+
"do I have to go and look this up in another tab?".
|
|
3
|
+
|
|
4
|
+
Every value comes from SupportDesk::ContextCard, which is plain Ruby
|
|
5
|
+
reading the host's own `supportable` methods. The host decides what an
|
|
6
|
+
agent needs to see by writing `support_context` on their model; this
|
|
7
|
+
file just lays it out. %>
|
|
8
|
+
<section class="rounded-lg border border-slate-200 bg-white p-4">
|
|
9
|
+
<h2 class="text-sm font-semibold text-slate-900"><%= t("support_desk.console.context.title") %></h2>
|
|
10
|
+
|
|
11
|
+
<dl class="mt-3 space-y-2 text-sm">
|
|
12
|
+
<div class="flex justify-between gap-3">
|
|
13
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.requester") %></dt>
|
|
14
|
+
<dd class="text-right font-medium"><%= card.requester_name %></dd>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="flex justify-between gap-3">
|
|
18
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.open_tickets") %></dt>
|
|
19
|
+
<dd class="text-right tabular-nums"><%= card.requester_open_tickets %></dd>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<% if card.requester_since %>
|
|
23
|
+
<div class="flex justify-between gap-3">
|
|
24
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.member_since") %></dt>
|
|
25
|
+
<dd class="text-right"><%= l(card.requester_since.to_date, format: :long) %></dd>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
|
|
29
|
+
<div class="flex justify-between gap-3">
|
|
30
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.topic") %></dt>
|
|
31
|
+
<dd class="text-right"><%= card.topic_label %></dd>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<% if card.status.present? %>
|
|
35
|
+
<div class="flex justify-between gap-3">
|
|
36
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.subject_status") %></dt>
|
|
37
|
+
<dd class="text-right font-medium"><%= card.status %></dd>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<% card.pairs.each do |key, value| %>
|
|
42
|
+
<div class="flex justify-between gap-3">
|
|
43
|
+
<dt class="text-slate-500"><%= key %></dt>
|
|
44
|
+
<dd class="text-right"><%= value %></dd>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</dl>
|
|
48
|
+
|
|
49
|
+
<% if card.subject_url.present? %>
|
|
50
|
+
<%= link_to t("support_desk.console.context.open_subject"), card.subject_url,
|
|
51
|
+
class: "mt-3 inline-block text-sm font-medium text-slate-900 underline underline-offset-2" %>
|
|
52
|
+
<% end %>
|
|
53
|
+
</section>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<%# One message, from the desk's side of the glass: the requester on the
|
|
2
|
+
left, our answers on the right.
|
|
3
|
+
|
|
4
|
+
Who wrote it is a real question here. An agent's answer is SENT by the
|
|
5
|
+
desk and AUTHORED by the human (chats' S3 seam), which is what lets the
|
|
6
|
+
requester see one counterpart while the console still names the person —
|
|
7
|
+
so `sender` decides the side and `author` decides the signature. A reply
|
|
8
|
+
from an agent who isn't the assignee is a drop-in: the timeline records
|
|
9
|
+
it, and the signature is what makes it visible here. %>
|
|
10
|
+
<% from_requester = message.sender_type == ticket.requester_type && message.sender_id.to_s == ticket.requester_id.to_s %>
|
|
11
|
+
|
|
12
|
+
<% if message.system? %>
|
|
13
|
+
<p class="text-center text-xs text-slate-400"><%= message.visible_body %></p>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="flex gap-2 <%= "flex-row-reverse" unless from_requester %>">
|
|
16
|
+
<%= chats_messager_avatar(message.sender, css_class: "h-8 w-8 shrink-0 rounded-full") %>
|
|
17
|
+
|
|
18
|
+
<div class="max-w-[80%] rounded-lg px-3 py-2 text-sm <%= from_requester ? "bg-slate-100 text-slate-900" : "bg-slate-900 text-white" %>">
|
|
19
|
+
<% if message.deleted? %>
|
|
20
|
+
<em class="opacity-70"><%= message.visible_body %></em>
|
|
21
|
+
<% else %>
|
|
22
|
+
<% if message.body.present? %>
|
|
23
|
+
<%# Escaped text with pre-wrap, never simple_format: chat semantics
|
|
24
|
+
are literal, and every newline somebody typed is one they meant. %>
|
|
25
|
+
<div class="whitespace-pre-wrap break-words"><%= message.body %></div>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<% if message.attachments? %>
|
|
29
|
+
<ul class="mt-1 space-y-0.5">
|
|
30
|
+
<% message.files.each do |file| %>
|
|
31
|
+
<li>
|
|
32
|
+
<%= link_to file.filename.to_s, console_file_path(file),
|
|
33
|
+
target: "_blank", rel: "noopener", class: "underline underline-offset-2" %>
|
|
34
|
+
</li>
|
|
35
|
+
<% end %>
|
|
36
|
+
</ul>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<div class="mt-1 flex items-center gap-2 text-[11px] <%= from_requester ? "text-slate-500" : "text-white/60" %>">
|
|
41
|
+
<% if (signature = chats_message_signature(message)) %>
|
|
42
|
+
<span><%= signature %></span>
|
|
43
|
+
<% end %>
|
|
44
|
+
<time datetime="<%= message.created_at.iso8601 %>"><%= l(message.created_at, format: :short) %></time>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<% end %>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%# The nav badge: how many cases this agent should feel responsible for —
|
|
2
|
+
their own, plus everything nobody has picked up.
|
|
3
|
+
|
|
4
|
+
Drop it into your admin nav, passing whoever is logged in:
|
|
5
|
+
|
|
6
|
+
render "madmin/support_tickets/nav_badge", agent: current_user
|
|
7
|
+
|
|
8
|
+
`Queue#badge` is one COUNT cached 30 seconds per agent, so putting it in
|
|
9
|
+
a layout that renders on every admin page costs nothing. Renders nothing
|
|
10
|
+
at all for somebody who isn't an agent, so it is safe in a shared nav. %>
|
|
11
|
+
<% if agent.respond_to?(:support_agent?) && agent.support_agent? %>
|
|
12
|
+
<% count = SupportDesk::Queue.for(agent).badge %>
|
|
13
|
+
<span class="inline-flex items-center gap-2">
|
|
14
|
+
<%= t("support_desk.console.nav.title") %>
|
|
15
|
+
<% if count.positive? %>
|
|
16
|
+
<span class="rounded-full bg-red-600 px-1.5 text-xs font-medium tabular-nums text-white"><%= count %></span>
|
|
17
|
+
<% end %>
|
|
18
|
+
</span>
|
|
19
|
+
<% end %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# The queue tabs, straight from `queue.tabs` — [[:awaiting, "Pendientes", 4], …]
|
|
2
|
+
with the labels already translated, in the order an agent works them.
|
|
3
|
+
The selected tab travels in the URL (`?tab=`), so a tab is a link
|
|
4
|
+
anybody can bookmark, send to a colleague, or reload. %>
|
|
5
|
+
<nav class="mb-4 flex flex-wrap gap-1" aria-label="<%= t("support_desk.console.queue.tabs_label") %>">
|
|
6
|
+
<% queue.tabs.each do |tab, label, count| %>
|
|
7
|
+
<% current = tab == scope %>
|
|
8
|
+
<%= link_to console_tickets_path(:index, tab: tab),
|
|
9
|
+
"aria-current": ("page" if current),
|
|
10
|
+
class: "inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium #{current ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100"}" do %>
|
|
11
|
+
<%= label %>
|
|
12
|
+
<span class="rounded-full <%= current ? "bg-white/20" : "bg-slate-200 text-slate-700" %> px-1.5 text-xs tabular-nums"><%= count %></span>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</nav>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<%# One queue row: who is asking, what about, what was said last, how long
|
|
2
|
+
they have been waiting, who holds it, and where it came in through.
|
|
3
|
+
|
|
4
|
+
The waiting chip is coloured by the desk's OWN thresholds — the ticket
|
|
5
|
+
answers `at_risk?` and `overdue?` against `config.at_risk_after` and
|
|
6
|
+
`config.reply_within`, so a desk that promises an answer in an hour goes
|
|
7
|
+
red in an hour without anybody restyling this file.
|
|
8
|
+
|
|
9
|
+
Nothing here queries: the index preloads requester, assignee, desk and
|
|
10
|
+
the last message with its sender and author. %>
|
|
11
|
+
<li>
|
|
12
|
+
<%= link_to console_ticket_path(ticket), class: "flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3 hover:bg-slate-50" do %>
|
|
13
|
+
<div class="min-w-0 flex-1">
|
|
14
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
15
|
+
<span class="font-medium"><%= ticket.label %></span>
|
|
16
|
+
|
|
17
|
+
<% pill = ticket.closed? ? "bg-slate-100 text-slate-600" : ticket.snoozed? ? "bg-indigo-50 text-indigo-700" : "bg-emerald-50 text-emerald-700" %>
|
|
18
|
+
<span class="rounded-full <%= pill %> px-2 py-0.5 text-xs font-medium">
|
|
19
|
+
<%= t("support_desk.status.#{ticket.status}") %>
|
|
20
|
+
</span>
|
|
21
|
+
|
|
22
|
+
<span class="font-mono text-xs text-slate-400"><%= ticket.reference %></span>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<p class="mt-0.5 text-sm text-slate-500">
|
|
26
|
+
<%= Chats.display_name_for(ticket.requester) %> · <%= ticket.topic&.full_label %>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<%# Speaker prefix in one expression: an agent's answer is SENT by the
|
|
30
|
+
desk and AUTHORED by the human, so `author` is the agent when there
|
|
31
|
+
is one and nil when the requester wrote it. %>
|
|
32
|
+
<% preview = ticket.conversation&.last_message %>
|
|
33
|
+
<% if preview && !preview.system? && preview.visible_body.present? %>
|
|
34
|
+
<p class="mt-1 truncate text-sm text-slate-600">
|
|
35
|
+
<span class="font-medium text-slate-700"><%= preview.author&.try(:support_agent_name) || Chats.display_name_for(preview.sender) %>:</span>
|
|
36
|
+
<%= truncate(preview.visible_body, length: 90) %>
|
|
37
|
+
</p>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="flex shrink-0 flex-col items-end gap-1 text-xs">
|
|
42
|
+
<% if ticket.waiting_for %>
|
|
43
|
+
<% chip = ticket.overdue? ? "bg-red-50 text-red-700" : ticket.at_risk? ? "bg-amber-50 text-amber-800" : "bg-slate-100 text-slate-600" %>
|
|
44
|
+
<span class="rounded-full <%= chip %> px-2 py-0.5 font-medium">
|
|
45
|
+
<%= t("support_desk.console.row.waiting", duration: ticket.summary.waiting) %>
|
|
46
|
+
</span>
|
|
47
|
+
<% end %>
|
|
48
|
+
|
|
49
|
+
<span class="text-slate-500">
|
|
50
|
+
<%= ticket.assignee&.support_agent_name || t("support_desk.console.row.unassigned") %>
|
|
51
|
+
</span>
|
|
52
|
+
|
|
53
|
+
<span class="text-slate-400"><%= ticket.channels_summary %></span>
|
|
54
|
+
</div>
|
|
55
|
+
<% end %>
|
|
56
|
+
</li>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<%# The whole story of the case: what was said and what was DONE, merged by
|
|
2
|
+
time. This is where a hand-off note, a drop-in and a topic change show
|
|
3
|
+
up — the half of the case the requester never sees.
|
|
4
|
+
|
|
5
|
+
Collapsed by default, because an agent opens a case to answer it, not to
|
|
6
|
+
audit it. `<details>` rather than a toggle controller: the browser has
|
|
7
|
+
had this for a decade and it survives a broken bundle. %>
|
|
8
|
+
<details class="rounded-lg border border-slate-200 bg-white p-4">
|
|
9
|
+
<summary class="cursor-pointer text-sm font-semibold"><%= t("support_desk.console.timeline.title") %></summary>
|
|
10
|
+
|
|
11
|
+
<ol class="mt-3 space-y-2 text-sm">
|
|
12
|
+
<% ticket.timeline.each do |entry| %>
|
|
13
|
+
<li class="flex gap-2">
|
|
14
|
+
<time class="w-28 shrink-0 text-xs text-slate-400" datetime="<%= entry.at&.iso8601 %>">
|
|
15
|
+
<%= l(entry.at, format: :short) if entry.at %>
|
|
16
|
+
</time>
|
|
17
|
+
|
|
18
|
+
<div class="min-w-0">
|
|
19
|
+
<span class="font-medium text-slate-700">
|
|
20
|
+
<%= t("support_desk.console.timeline.kinds.#{entry.kind}", default: entry.kind.to_s.humanize) %>
|
|
21
|
+
</span>
|
|
22
|
+
|
|
23
|
+
<% actor = entry.actor %>
|
|
24
|
+
<% if actor %>
|
|
25
|
+
<span class="text-slate-500">
|
|
26
|
+
· <%= actor.is_a?(Symbol) ? t("support_desk.console.timeline.system") : (actor.try(:support_agent_name) || Chats.display_name_for(actor)) %>
|
|
27
|
+
</span>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<% if entry.body.present? %>
|
|
31
|
+
<p class="truncate text-slate-600"><%= truncate(entry.body, length: 120) %></p>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
</li>
|
|
35
|
+
<% end %>
|
|
36
|
+
</ol>
|
|
37
|
+
</details>
|