support_desk 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +998 -14
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +23 -1
- data/app/helpers/support_desk/engine_helper.rb +16 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/locales/support_desk.console.en.yml +63 -0
- data/config/locales/support_desk.console.es.yml +65 -0
- data/config/locales/support_desk.en.yml +15 -0
- data/config/locales/support_desk.es.yml +23 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +552 -2
- data/lib/support_desk/console.rb +290 -8
- data/lib/support_desk/context_card.rb +10 -1
- data/lib/support_desk/doctor.rb +144 -1
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +37 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +14 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/requester.rb +18 -0
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +334 -37
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/test_helpers.rb +152 -0
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- metadata +30 -8
|
@@ -206,6 +206,16 @@
|
|
|
206
206
|
cursor: pointer;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/* The way out of a machine. Quiet on purpose: it sits inside a live
|
|
210
|
+
conversation, where the loud button is the one that sends a message. */
|
|
211
|
+
.support-desk-button--quiet {
|
|
212
|
+
padding: 0.5rem 1rem;
|
|
213
|
+
border: 1px solid var(--support-desk-border);
|
|
214
|
+
background: transparent;
|
|
215
|
+
color: var(--support-desk-text);
|
|
216
|
+
font-weight: 600;
|
|
217
|
+
}
|
|
218
|
+
|
|
209
219
|
/* --- Notices ---------------------------------------------------------------- */
|
|
210
220
|
|
|
211
221
|
.support-desk-notice {
|
|
@@ -19,7 +19,10 @@ module SupportDesk
|
|
|
19
19
|
# Their cases: open ones as rows, closed ones folded away, and the door
|
|
20
20
|
# to open another.
|
|
21
21
|
def index
|
|
22
|
-
|
|
22
|
+
# `:assignee` because a row says who is on the case when that is a
|
|
23
|
+
# machine, and a name per row is a query per row.
|
|
24
|
+
tickets = current_requester.support_tickets
|
|
25
|
+
.includes(:desk, :subject, :assignee, conversation: { last_message: :sender })
|
|
23
26
|
@open_tickets = tickets.not_closed.recent_activity_first.to_a
|
|
24
27
|
@closed_tickets = tickets.closed.newest_first.limit(CLOSED_TICKETS_SHOWN).to_a
|
|
25
28
|
@closed_count = tickets.closed.count
|
|
@@ -70,6 +73,25 @@ module SupportDesk
|
|
|
70
73
|
redirect_to conversation_path_for(find_ticket)
|
|
71
74
|
end
|
|
72
75
|
|
|
76
|
+
# "Prefiero hablar con una persona." The one thing a requester can
|
|
77
|
+
# always do on their own case, and the reason an assistant is allowed to
|
|
78
|
+
# answer at all: nobody is ever trapped in a conversation with a machine.
|
|
79
|
+
#
|
|
80
|
+
# Scoped through their OWN cases, so a foreign id is a 404 and not a
|
|
81
|
+
# refusal that confirms the case exists. Idempotent by the model, so a
|
|
82
|
+
# second press is a second notice and nothing else.
|
|
83
|
+
def request_human
|
|
84
|
+
ticket = find_ticket
|
|
85
|
+
ticket.request_human!(by: current_requester, request: request)
|
|
86
|
+
redirect_to ticket_path(ticket), status: :see_other,
|
|
87
|
+
notice: t("support_desk.thread.human_requested_notice")
|
|
88
|
+
rescue SupportDesk::Locked, SupportDesk::InvalidTransition
|
|
89
|
+
# A closed case on a desk that locks them, or one the model won't
|
|
90
|
+
# reopen. The case is still theirs to read, so this is the door they
|
|
91
|
+
# came from with the reason on it — never a 500, and never silence.
|
|
92
|
+
redirect_to ticket_path(ticket), status: :see_other, alert: ticket.chat_locked_notice
|
|
93
|
+
end
|
|
94
|
+
|
|
73
95
|
private
|
|
74
96
|
|
|
75
97
|
def chat_rate_limit_messager = current_requester
|
|
@@ -54,6 +54,22 @@ module SupportDesk
|
|
|
54
54
|
tag.span(count > 99 ? "99+" : count, class: css_class)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# The door out of a machine, for a thread: "Prefiero hablar con una
|
|
58
|
+
# persona", the promise once somebody has asked, or nothing.
|
|
59
|
+
#
|
|
60
|
+
# <%= support_human_door(ticket) %>
|
|
61
|
+
#
|
|
62
|
+
# It is a helper rather than a partial hosts render by path because the
|
|
63
|
+
# thread is CHATS' screen, not this engine's: a host that has ejected
|
|
64
|
+
# chats' views drops this one line into their own and the door works,
|
|
65
|
+
# wherever their copy lives and whatever they renamed around it.
|
|
66
|
+
# Renders nothing when there is no case, so it is safe unconditionally.
|
|
67
|
+
def support_human_door(ticket)
|
|
68
|
+
return if ticket.nil?
|
|
69
|
+
|
|
70
|
+
render partial: "support_desk/tickets/human_door", locals: { ticket: ticket }
|
|
71
|
+
end
|
|
72
|
+
|
|
57
73
|
# --- What the bundled views render with -----------------------------------
|
|
58
74
|
|
|
59
75
|
# The gem's bundled stylesheet, into the host layout's <head> — where a
|
|
@@ -22,6 +22,21 @@
|
|
|
22
22
|
class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
|
|
23
23
|
<% end %>
|
|
24
24
|
|
|
25
|
+
<%# Pausing is not a policy change: it switches her off on THIS case and
|
|
26
|
+
throws away whatever she had proposed for it. Only one of the two ever
|
|
27
|
+
shows, because `actions_for` offers only one. %>
|
|
28
|
+
<% if actions.include?(:pause_assistant) %>
|
|
29
|
+
<%= button_to t("support_desk.console.actions.pause_assistant"),
|
|
30
|
+
console_ticket_path(ticket, :pause_assistant), method: :post,
|
|
31
|
+
class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% if actions.include?(:resume_assistant) %>
|
|
35
|
+
<%= button_to t("support_desk.console.actions.resume_assistant"),
|
|
36
|
+
console_ticket_path(ticket, :resume_assistant), method: :post,
|
|
37
|
+
class: "rounded-md border border-indigo-300 px-3 py-1.5 text-sm font-medium text-indigo-800 hover:bg-indigo-50" %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
25
40
|
<% if actions.include?(:reopen) %>
|
|
26
41
|
<%= button_to t("support_desk.console.actions.reopen"), console_ticket_path(ticket, :reopen), method: :post,
|
|
27
42
|
class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
|
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
<h2 class="text-sm font-semibold"><%= t("support_desk.console.assignment.title") %></h2>
|
|
15
15
|
|
|
16
16
|
<p class="mt-2 text-sm text-slate-600">
|
|
17
|
-
<% if ticket.
|
|
17
|
+
<% if ticket.held_by_assistant? %>
|
|
18
|
+
<%# A machine holding a case is not the same sentence as a colleague
|
|
19
|
+
holding it: what matters is how much rope she has left, because
|
|
20
|
+
that is when a person has to be here. %>
|
|
21
|
+
<% assistant = ticket.assignee %>
|
|
22
|
+
<% if assistant.max_turns %>
|
|
23
|
+
<%= t("support_desk.console.badges.assistant_holds", name: assistant.support_agent_name,
|
|
24
|
+
used: ticket.assistant_turns_count.to_i, max: assistant.max_turns) %>
|
|
25
|
+
<% else %>
|
|
26
|
+
<%= t("support_desk.console.badges.assistant_holds_unlimited", name: assistant.support_agent_name) %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% elsif ticket.assigned? %>
|
|
18
29
|
<%= t("support_desk.console.assignment.held_by", agent: ticket.assignee.support_agent_name) %>
|
|
19
30
|
<% else %>
|
|
20
31
|
<%= t("support_desk.console.assignment.unassigned") %>
|
|
@@ -36,8 +47,11 @@
|
|
|
36
47
|
<% if agents.any? && (actions.include?(:hand_off) || actions.include?(:assign)) %>
|
|
37
48
|
<% handing_off = actions.include?(:hand_off) %>
|
|
38
49
|
<%= form_with url: console_ticket_path(ticket, handing_off ? :hand_off : :assign), method: :post, class: "space-y-2" do %>
|
|
50
|
+
<%# Values are actor KEYS, not ids: the pool is people and a machine,
|
|
51
|
+
drawn from different tables, and a bare "1" could name either. %>
|
|
39
52
|
<%= select_tag :agent_id,
|
|
40
|
-
options_from_collection_for_select(agents,
|
|
53
|
+
options_from_collection_for_select(agents, ->(agent) { SupportDesk.actor_key(agent) },
|
|
54
|
+
:support_agent_name),
|
|
41
55
|
include_blank: t("support_desk.console.assignment.choose_agent"),
|
|
42
56
|
required: true,
|
|
43
57
|
class: "w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
composer that still works on the day the bundle fails to build. %>
|
|
9
9
|
<% composing_note = params[:compose].to_s == "note" %>
|
|
10
10
|
<% may_reply = actions.include?(:reply) %>
|
|
11
|
+
<%# "Editar" on a proposal opens THIS box with the proposal in it, at
|
|
12
|
+
`?compose=reply&draft=ID`. The concern decides whether that is a real
|
|
13
|
+
edit (the draft is still the pending one, and this agent is offered the
|
|
14
|
+
send) — a view never does. It is also where a stale send lands, with
|
|
15
|
+
whatever the reviewer had typed still in the box and the case's CURRENT
|
|
16
|
+
turn in the hidden field, because a redirect would have thrown their
|
|
17
|
+
words away. %>
|
|
18
|
+
<% editing = composing_note ? nil : support_editing_draft(ticket) %>
|
|
11
19
|
|
|
12
20
|
<section class="rounded-lg border border-slate-200 bg-white">
|
|
13
21
|
<nav class="flex gap-1 border-b border-slate-200 px-2 pt-2">
|
|
@@ -33,6 +41,25 @@
|
|
|
33
41
|
</div>
|
|
34
42
|
<% end %>
|
|
35
43
|
|
|
44
|
+
<% elsif editing %>
|
|
45
|
+
<% assistant_name = editing.author.try(:support_agent_name) || editing.author.to_s %>
|
|
46
|
+
<p class="mb-2 text-xs font-medium text-indigo-800">
|
|
47
|
+
<%= t("support_desk.console.draft.editing", name: assistant_name) %>
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<%= form_with url: console_ticket_path(ticket, :send_draft), method: :post do %>
|
|
51
|
+
<%= hidden_field_tag :draft_id, editing.id %>
|
|
52
|
+
<%= hidden_field_tag :seen_turn, support_seen_turn(ticket) %>
|
|
53
|
+
<%= text_area_tag :body, (params[:body].is_a?(String) && params[:body].present? ? params[:body] : editing.body),
|
|
54
|
+
rows: 5, class: "w-full rounded-md border border-indigo-300 p-2 text-sm" %>
|
|
55
|
+
<div class="mt-2 flex flex-wrap items-center justify-between gap-3">
|
|
56
|
+
<%= link_to t("support_desk.console.draft.cancel_edit"), console_ticket_path(ticket),
|
|
57
|
+
class: "text-xs text-slate-500 underline underline-offset-2 hover:text-slate-900" %>
|
|
58
|
+
<%= submit_tag t("support_desk.console.actions.send_edited_draft"),
|
|
59
|
+
class: "rounded-md bg-indigo-700 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-800" %>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
36
63
|
<% elsif may_reply %>
|
|
37
64
|
<%# multipart is explicit because `form_with` only infers it from the
|
|
38
65
|
FORM BUILDER's file_field, and this is a file_field_tag — without
|
|
@@ -49,8 +49,36 @@
|
|
|
49
49
|
<dd class="text-right"><%= value %></dd>
|
|
50
50
|
</div>
|
|
51
51
|
<% end %>
|
|
52
|
+
|
|
53
|
+
<%# What she may do here, and the rule that decided it. The LEVEL is the
|
|
54
|
+
answer an agent needs ("she can only propose on this one"); the
|
|
55
|
+
`because` is written for whoever is reading a stack trace, so it
|
|
56
|
+
rides in the tooltip rather than on the card. %>
|
|
57
|
+
<% if (assistant = card.ticket.assistant) %>
|
|
58
|
+
<% policy = card.ticket.assistant_policy(assistant) %>
|
|
59
|
+
<div class="flex justify-between gap-3">
|
|
60
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.assistant") %></dt>
|
|
61
|
+
<dd class="text-right font-medium" title="<%= policy.because %>"><%= policy.level %></dd>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|
|
52
64
|
</dl>
|
|
53
65
|
|
|
66
|
+
<%# The host's own words about the person who is asking — the same
|
|
67
|
+
`support_context` a brief hands the machine, on the screen for the
|
|
68
|
+
person doing the same job. %>
|
|
69
|
+
<% requester_context = card.requester.try(:support_context) || {} %>
|
|
70
|
+
<% if requester_context.respond_to?(:each_pair) && requester_context.any? %>
|
|
71
|
+
<h3 class="mt-4 text-sm font-semibold text-slate-900"><%= t("support_desk.console.context.requester_context") %></h3>
|
|
72
|
+
<dl class="mt-2 space-y-2 text-sm">
|
|
73
|
+
<% requester_context.each_pair do |key, value| %>
|
|
74
|
+
<div class="flex justify-between gap-3">
|
|
75
|
+
<dt class="text-slate-500"><%= key.to_s.humanize %></dt>
|
|
76
|
+
<dd class="text-right"><%= value %></dd>
|
|
77
|
+
</div>
|
|
78
|
+
<% end %>
|
|
79
|
+
</dl>
|
|
80
|
+
<% end %>
|
|
81
|
+
|
|
54
82
|
<% if card.subject_url.present? %>
|
|
55
83
|
<%= link_to t("support_desk.console.context.open_subject"), card.subject_url,
|
|
56
84
|
class: "mt-3 inline-block text-sm font-medium text-slate-900 underline underline-offset-2" %>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<%# The assistant's proposal, and the two decisions a person makes about it.
|
|
2
|
+
|
|
3
|
+
The card sits ABOVE the composer on purpose: it is the first thing on
|
|
4
|
+
the screen that wants an answer, and the composer underneath is where
|
|
5
|
+
the same person writes their own words when this one is wrong.
|
|
6
|
+
|
|
7
|
+
Nothing here is sent by a machine. "Enviar" posts a reply FROM THE
|
|
8
|
+
AGENT — their signature, their case, their responsibility — carrying
|
|
9
|
+
the turn this page was rendered with, so a proposal approved from a page
|
|
10
|
+
that predates the customer's next message comes back as a warning
|
|
11
|
+
instead of landing in a conversation that moved on.
|
|
12
|
+
|
|
13
|
+
Buttons come from `actions`, like everywhere else in this console: a
|
|
14
|
+
desk where this agent may not answer shows the proposal and offers only
|
|
15
|
+
"Descartar", which is exactly what the server would accept. %>
|
|
16
|
+
<%# The author by the name the requester is reading (`support_agent_name` is
|
|
17
|
+
her disclosed name): whoever sends this is about to own what it says, so
|
|
18
|
+
the card names her the way the conversation does. %>
|
|
19
|
+
<% assistant_name = draft.author.try(:support_agent_name) || draft.author.to_s %>
|
|
20
|
+
|
|
21
|
+
<section class="rounded-lg border border-indigo-200 bg-indigo-50 p-4" id="<%= dom_id(draft) %>">
|
|
22
|
+
<header class="flex flex-wrap items-center justify-between gap-2">
|
|
23
|
+
<h2 class="text-sm font-semibold text-indigo-900">
|
|
24
|
+
<span aria-hidden="true">🤖</span>
|
|
25
|
+
<%= t("support_desk.console.draft.title", name: assistant_name) %>
|
|
26
|
+
</h2>
|
|
27
|
+
|
|
28
|
+
<% if draft.confidence_percent %>
|
|
29
|
+
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-indigo-700">
|
|
30
|
+
<%= t("support_desk.console.draft.confidence", percent: draft.confidence_percent) %>
|
|
31
|
+
</span>
|
|
32
|
+
<% end %>
|
|
33
|
+
</header>
|
|
34
|
+
|
|
35
|
+
<%# Escaped text with pre-wrap, never simple_format: what a model wrote is
|
|
36
|
+
literal text, and it is about to be sent as somebody's own words. %>
|
|
37
|
+
<div class="mt-3 whitespace-pre-wrap break-words rounded-md bg-white p-3 text-sm text-slate-900"><%= draft.body %></div>
|
|
38
|
+
|
|
39
|
+
<% sources = Array(draft.sources).select { |source| source.is_a?(Hash) && source["title"].present? } %>
|
|
40
|
+
<% if sources.any? %>
|
|
41
|
+
<div class="mt-3 text-xs">
|
|
42
|
+
<p class="font-semibold text-indigo-900"><%= t("support_desk.console.draft.sources") %></p>
|
|
43
|
+
<ul class="mt-1 space-y-0.5">
|
|
44
|
+
<% sources.each do |source| %>
|
|
45
|
+
<li>
|
|
46
|
+
<%# http(s) only, and checked HERE as well as on the model: a
|
|
47
|
+
`javascript:` url in a citation is a link an agent clicks. %>
|
|
48
|
+
<% if source["url"].to_s.match?(%r{\Ahttps?://}i) %>
|
|
49
|
+
<%= link_to source["title"], source["url"], target: "_blank", rel: "noopener",
|
|
50
|
+
class: "text-indigo-700 underline underline-offset-2" %>
|
|
51
|
+
<% else %>
|
|
52
|
+
<span class="text-slate-600"><%= source["title"] %></span>
|
|
53
|
+
<% end %>
|
|
54
|
+
</li>
|
|
55
|
+
<% end %>
|
|
56
|
+
</ul>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
|
|
60
|
+
<% if draft.files_attached? %>
|
|
61
|
+
<div class="mt-3 text-xs">
|
|
62
|
+
<p class="font-semibold text-indigo-900"><%= t("support_desk.console.draft.attachments") %></p>
|
|
63
|
+
<ul class="mt-1 space-y-0.5">
|
|
64
|
+
<% draft.files.each do |file| %>
|
|
65
|
+
<li>
|
|
66
|
+
<%= link_to file.filename.to_s, console_file_path(file), target: "_blank", rel: "noopener",
|
|
67
|
+
class: "text-indigo-700 underline underline-offset-2" %>
|
|
68
|
+
</li>
|
|
69
|
+
<% end %>
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
<% end %>
|
|
73
|
+
|
|
74
|
+
<% if draft.stale? %>
|
|
75
|
+
<p class="mt-3 rounded-md bg-amber-100 p-2 text-xs font-medium text-amber-900">
|
|
76
|
+
<%= t("support_desk.console.draft.stale") %>
|
|
77
|
+
</p>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
80
|
+
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
81
|
+
<% if actions.include?(:send_draft) %>
|
|
82
|
+
<%# `id: nil` on every hidden field in this card: the composer below
|
|
83
|
+
carries the same two names in its edit mode, and two elements with
|
|
84
|
+
one id is a form field that `fill_in` and a label can both reach
|
|
85
|
+
the wrong one of. The NAME is what a request is made of. %>
|
|
86
|
+
<%= form_with url: console_ticket_path(ticket, :send_draft), method: :post, class: "inline" do %>
|
|
87
|
+
<%= hidden_field_tag :draft_id, draft.id, id: nil %>
|
|
88
|
+
<%# The turn THIS page was rendered with. Not the one the form
|
|
89
|
+
submits at: the whole point is to compare them. %>
|
|
90
|
+
<%= hidden_field_tag :seen_turn, support_seen_turn(ticket), id: nil %>
|
|
91
|
+
<%= submit_tag t("support_desk.console.actions.send_draft"),
|
|
92
|
+
class: "rounded-md bg-indigo-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-800" %>
|
|
93
|
+
<% end %>
|
|
94
|
+
|
|
95
|
+
<%= link_to t("support_desk.console.actions.edit_draft"),
|
|
96
|
+
console_ticket_path(ticket, :show, compose: :reply, draft: draft.id),
|
|
97
|
+
class: "rounded-md border border-indigo-300 px-3 py-1.5 text-sm font-medium text-indigo-800 hover:bg-white" %>
|
|
98
|
+
<% end %>
|
|
99
|
+
|
|
100
|
+
<% if actions.include?(:reject_draft) %>
|
|
101
|
+
<%= form_with url: console_ticket_path(ticket, :reject_draft), method: :post,
|
|
102
|
+
class: "flex flex-wrap items-center gap-2" do %>
|
|
103
|
+
<%# WHICH proposal is being discarded, in the form: the action looks
|
|
104
|
+
the row up by id in any status, so a discard with nothing to name
|
|
105
|
+
is refused before the model is asked. %>
|
|
106
|
+
<%= hidden_field_tag :draft_id, draft.id, id: nil %>
|
|
107
|
+
<%= text_field_tag :reason, nil, placeholder: t("support_desk.console.draft.reason_placeholder"),
|
|
108
|
+
class: "rounded-md border border-indigo-200 px-2 py-1.5 text-sm" %>
|
|
109
|
+
<%= submit_tag t("support_desk.console.actions.reject_draft"),
|
|
110
|
+
class: "rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
|
|
111
|
+
<% end %>
|
|
112
|
+
<% end %>
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
from an agent who isn't the assignee is a drop-in: the timeline records
|
|
9
9
|
it, and the signature is what makes it visible here. %>
|
|
10
10
|
<% from_requester = message.sender_type == ticket.requester_type && message.sender_id.to_s == ticket.requester_id.to_s %>
|
|
11
|
+
<%# Two marks, and they mean opposite things. The robot says a MACHINE
|
|
12
|
+
wrote this and it went out as it stood — what the requester saw depends
|
|
13
|
+
on the disclosure mode, but the desk always knows. The "propuesta de"
|
|
14
|
+
line says a machine wrote it and a PERSON read it and sent it: that
|
|
15
|
+
message is theirs, and the mark is provenance, not authorship.
|
|
16
|
+
|
|
17
|
+
Both are staff-only: this partial renders the console, never the thread.
|
|
18
|
+
The provenance stamp is read from the message's own metadata so a host
|
|
19
|
+
that changes disclosure mode tomorrow doesn't rewrite yesterday's
|
|
20
|
+
bubbles. %>
|
|
21
|
+
<% stamp = message.try(:metadata) %>
|
|
22
|
+
<% stamp = stamp.is_a?(Hash) ? stamp["support_desk"] : nil %>
|
|
23
|
+
<% stamp = stamp.is_a?(Hash) ? stamp : {} %>
|
|
24
|
+
<% from_assistant = ticket.assistant_message?(message) %>
|
|
25
|
+
<% drafted_by = stamp["drafted_by"] %>
|
|
11
26
|
|
|
12
27
|
<% if message.system? %>
|
|
13
28
|
<p class="text-center text-xs text-slate-400"><%= message.visible_body %></p>
|
|
@@ -37,7 +52,25 @@
|
|
|
37
52
|
<% end %>
|
|
38
53
|
<% end %>
|
|
39
54
|
|
|
40
|
-
<div class="mt-1 flex items-center gap-2 text-[11px] <%= from_requester ? "text-slate-500" : "text-white/60" %>">
|
|
55
|
+
<div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] <%= from_requester ? "text-slate-500" : "text-white/60" %>">
|
|
56
|
+
<% if from_assistant %>
|
|
57
|
+
<%# The declared confidence rides in the tooltip rather than on
|
|
58
|
+
the bubble: it is a number a model made up about itself, and
|
|
59
|
+
it belongs where somebody looking for it can find it. %>
|
|
60
|
+
<% confidence = stamp["confidence"] %>
|
|
61
|
+
<%= tag.span("🤖", "aria-hidden": true,
|
|
62
|
+
title: (t("support_desk.console.draft.confidence", percent: (confidence.to_f * 100).round) if confidence)) %>
|
|
63
|
+
<% end %>
|
|
64
|
+
|
|
65
|
+
<% if drafted_by.present? %>
|
|
66
|
+
<%# The name of whoever proposed it. The desk's own assistant
|
|
67
|
+
answers without a query; an assistant a host has since
|
|
68
|
+
replaced falls back to the desk, because a lookup per bubble
|
|
69
|
+
is a page that gets slower the more the machine helped. %>
|
|
70
|
+
<% proposer = ticket.assistant if ticket.assistant && SupportDesk.actor_key(ticket.assistant) == drafted_by %>
|
|
71
|
+
<span><%= t("support_desk.console.draft.from", name: proposer&.support_agent_name || ticket.desk.name) %></span>
|
|
72
|
+
<% end %>
|
|
73
|
+
|
|
41
74
|
<% if (signature = chats_message_signature(message)) %>
|
|
42
75
|
<span><%= signature %></span>
|
|
43
76
|
<% end %>
|
|
@@ -21,6 +21,23 @@
|
|
|
21
21
|
|
|
22
22
|
<span class="font-mono text-xs text-slate-400"><%= ticket.reference %></span>
|
|
23
23
|
|
|
24
|
+
<%# The two rows an agent must not scroll past. "Necesita persona"
|
|
25
|
+
is a case a machine could not finish; "Propuesta lista" is one
|
|
26
|
+
where the work is already written and needs reading. Both are
|
|
27
|
+
free here — the index preloads `pending_draft`, and the flag is
|
|
28
|
+
a column. %>
|
|
29
|
+
<% if ticket.human_required? %>
|
|
30
|
+
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900">
|
|
31
|
+
<%= t("support_desk.console.badges.needs_human") %>
|
|
32
|
+
</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<% if ticket.pending_draft %>
|
|
36
|
+
<span class="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-800">
|
|
37
|
+
<%= t("support_desk.console.badges.pending_draft") %>
|
|
38
|
+
</span>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
24
41
|
<%# A case we started reads differently from one somebody asked for:
|
|
25
42
|
nobody is waiting on us in it until they answer. %>
|
|
26
43
|
<% if ticket.opened_by_support? %>
|
|
@@ -54,6 +71,9 @@
|
|
|
54
71
|
</span>
|
|
55
72
|
<% end %>
|
|
56
73
|
|
|
74
|
+
<%# The holder, by the name the REQUESTER is reading: an assistant
|
|
75
|
+
answers `support_agent_name` with her disclosed name, so a desk
|
|
76
|
+
never has to look up what the customer thinks they are talking to. %>
|
|
57
77
|
<span class="text-slate-500">
|
|
58
78
|
<%= ticket.assignee&.support_agent_name || t("support_desk.console.row.unassigned") %>
|
|
59
79
|
</span>
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
Collapsed by default, because an agent opens a case to answer it, not to
|
|
6
6
|
audit it. `<details>` rather than a toggle controller: the browser has
|
|
7
|
-
had this for a decade and it survives a broken bundle.
|
|
7
|
+
had this for a decade and it survives a broken bundle.
|
|
8
|
+
|
|
9
|
+
Three kinds of moment now. A message and an event read as they always
|
|
10
|
+
did. A DRAFT reads greyed, because it is the one thing here that was
|
|
11
|
+
never said to anybody: a proposal somebody discarded, replaced, or sent
|
|
12
|
+
in somebody else's words. %>
|
|
8
13
|
<details class="rounded-lg border border-slate-200 bg-white p-4">
|
|
9
14
|
<summary class="cursor-pointer text-sm font-semibold"><%= t("support_desk.console.timeline.title") %></summary>
|
|
10
15
|
|
|
@@ -15,22 +20,71 @@
|
|
|
15
20
|
<%= l(entry.at, format: :short) if entry.at %>
|
|
16
21
|
</time>
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
<% actor = entry.actor %>
|
|
24
|
+
<% actor_name = if actor.nil?
|
|
25
|
+
nil
|
|
26
|
+
elsif actor.is_a?(Symbol)
|
|
27
|
+
t("support_desk.console.timeline.system")
|
|
28
|
+
else
|
|
29
|
+
actor.try(:support_agent_name) || Chats.display_name_for(actor)
|
|
30
|
+
end %>
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
<% if entry.draft? %>
|
|
33
|
+
<%# What the machine proposed, and what became of it. The event
|
|
34
|
+
beside it says who decided; this is what they decided ABOUT. %>
|
|
35
|
+
<% draft = entry.draft %>
|
|
36
|
+
<div class="min-w-0 text-slate-400">
|
|
37
|
+
<span class="font-medium">
|
|
38
|
+
<% case draft.status %>
|
|
39
|
+
<% when "sent" %>
|
|
40
|
+
<%= t("support_desk.console.draft.sent_by", name: actor_name) %>
|
|
41
|
+
<% when "rejected" %>
|
|
42
|
+
<%= t("support_desk.console.draft.rejected_by", name: actor_name) %>
|
|
43
|
+
<% when "superseded" %>
|
|
44
|
+
<%= t("support_desk.console.timeline.draft_superseded") %>
|
|
45
|
+
<% else %>
|
|
46
|
+
<%= t("support_desk.console.timeline.draft_expired") %>
|
|
47
|
+
<% end %>
|
|
27
48
|
</span>
|
|
28
|
-
|
|
49
|
+
<% if entry.body.present? %>
|
|
50
|
+
<p class="truncate"><%= truncate(entry.body, length: 120) %></p>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% if draft.rejection_reason.present? %>
|
|
53
|
+
<p class="truncate italic"><%= truncate(draft.rejection_reason, length: 120) %></p>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<% elsif (sentence = support_console_timeline_sentence(entry, actor_name)) %>
|
|
58
|
+
<%# The kinds the assistants added say the whole thing in one
|
|
59
|
+
sentence, with their own payload in it: "who did what to whom"
|
|
60
|
+
is the only reading of these that helps. %>
|
|
61
|
+
<div class="min-w-0">
|
|
62
|
+
<span class="text-slate-700"><%= sentence %></span>
|
|
63
|
+
<% if entry.kind == :escalated && entry.event.summary.present? %>
|
|
64
|
+
<%# Her hand-over note: the one piece of a machine's reasoning
|
|
65
|
+
a person actually needs, so it gets room rather than a
|
|
66
|
+
truncated line. %>
|
|
67
|
+
<p class="mt-1 whitespace-pre-wrap rounded-md bg-amber-50 p-2 text-xs text-amber-900">
|
|
68
|
+
<%= entry.event.summary %>
|
|
69
|
+
</p>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<% else %>
|
|
74
|
+
<div class="min-w-0">
|
|
75
|
+
<span class="font-medium text-slate-700">
|
|
76
|
+
<%= t("support_desk.console.timeline.kinds.#{entry.kind}", default: entry.kind.to_s.humanize) %>
|
|
77
|
+
</span>
|
|
78
|
+
|
|
79
|
+
<% if actor_name %>
|
|
80
|
+
<span class="text-slate-500">· <%= actor_name %></span>
|
|
81
|
+
<% end %>
|
|
29
82
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
83
|
+
<% if entry.body.present? %>
|
|
84
|
+
<p class="truncate text-slate-600"><%= truncate(entry.body, length: 120) %></p>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
87
|
+
<% end %>
|
|
34
88
|
</li>
|
|
35
89
|
<% end %>
|
|
36
90
|
</ol>
|
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
<div class="grid gap-6 lg:grid-cols-3">
|
|
34
34
|
<div class="space-y-4 lg:col-span-2">
|
|
35
35
|
<%= render "transcript", ticket: @ticket %>
|
|
36
|
+
<%# The proposal, when there is one waiting: above the composer,
|
|
37
|
+
because it is the thing on this screen that wants an answer. %>
|
|
38
|
+
<% if (draft = support_pending_draft) %>
|
|
39
|
+
<%= render "draft", ticket: @ticket, draft: draft, actions: @actions %>
|
|
40
|
+
<% end %>
|
|
36
41
|
<%= render "composer", ticket: @ticket, actions: @actions %>
|
|
37
42
|
</div>
|
|
38
43
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# "Prefiero hablar con una persona."
|
|
2
|
+
|
|
3
|
+
The one control on this screen that is never taken away while a machine
|
|
4
|
+
is in play: a customer who wants a person gets a person, and pressing
|
|
5
|
+
this is the whole of it — no wording to get right, no phrase to guess.
|
|
6
|
+
|
|
7
|
+
Three states, and the middle one is the point. While the assistant is in
|
|
8
|
+
play the button is here. Once somebody has asked (the customer, the
|
|
9
|
+
assistant, or the sweep that notices she went quiet) the button becomes
|
|
10
|
+
the promise, because a door that stays pressable after it worked is a
|
|
11
|
+
door people press again wondering whether it did. A case nobody can
|
|
12
|
+
write to shows nothing at all: it is over, and a button that only 500s
|
|
13
|
+
is worse than silence.
|
|
14
|
+
|
|
15
|
+
`ticket.assistant_in_play?` counts HISTORY as well as configuration, so
|
|
16
|
+
the door does not vanish from a live conversation because somebody
|
|
17
|
+
edited an initializer. %>
|
|
18
|
+
<% if ticket.human_required? %>
|
|
19
|
+
<p class="support-desk-notice__title"><%= t("support_desk.thread.handled_by_humans") %></p>
|
|
20
|
+
<% elsif ticket.assistant_in_play? && !ticket.chat_locked? %>
|
|
21
|
+
<%= button_to t("support_desk.thread.human_door"),
|
|
22
|
+
support_desk_routes.request_human_ticket_path(ticket),
|
|
23
|
+
method: :post, class: "support-desk-button support-desk-button--quiet" %>
|
|
24
|
+
<% end %>
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
|
|
19
19
|
<span class="chats-row__subject"><%= support_ticket_state(ticket) %></span>
|
|
20
20
|
|
|
21
|
+
<%# Who is on it, when that is not simply "us". A machine answering is
|
|
22
|
+
something the person is entitled to know before they read the
|
|
23
|
+
answer, so the row says so by her disclosed name — and the moment
|
|
24
|
+
a person has been asked for, it says THAT instead, because it is
|
|
25
|
+
the only one of the two that is a promise. %>
|
|
26
|
+
<% if ticket.human_required? %>
|
|
27
|
+
<span class="chats-row__subject"><%= t("support_desk.thread.handled_by_humans") %></span>
|
|
28
|
+
<% elsif ticket.held_by_assistant? %>
|
|
29
|
+
<span class="chats-row__subject">
|
|
30
|
+
<%= t("support_desk.thread.handled_by_assistant", name: ticket.assignee.support_agent_name) %>
|
|
31
|
+
</span>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
21
34
|
<span class="chats-row__bottom">
|
|
22
35
|
<span class="chats-row__preview">
|
|
23
36
|
<%= ticket.conversation ? chats_preview_for(ticket.conversation, current_requester) : "" %>
|