support_desk 0.1.3 → 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 +126 -0
- data/README.md +1101 -22
- data/Rakefile +16 -0
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +28 -4
- data/app/helpers/support_desk/engine_helper.rb +23 -1
- 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 +33 -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/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- 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/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +89 -0
- data/config/locales/support_desk.console.es.yml +91 -0
- data/config/locales/support_desk.en.yml +18 -0
- data/config/locales/support_desk.es.yml +29 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +26 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -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/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +63 -0
- data/lib/generators/support_desk/upgrade_generator.rb +66 -0
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +699 -3
- data/lib/support_desk/console.rb +634 -30
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +33 -1
- data/lib/support_desk/doctor.rb +195 -2
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +42 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +41 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +45 -12
- 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 +781 -120
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +160 -3
- 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/wizard.rb +4 -12
- data/lib/support_desk.rb +153 -0
- data/lib/tasks/support_desk.rake +73 -0
- metadata +35 -8
data/Rakefile
CHANGED
|
@@ -21,6 +21,22 @@ end
|
|
|
21
21
|
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
22
22
|
load "rails/tasks/engine.rake"
|
|
23
23
|
|
|
24
|
+
# `db:migrate:reset` — the command the README and CI give for the PostgreSQL
|
|
25
|
+
# leg — is `["db:drop", "db:create", "db:schema:dump", "db:migrate"]` in
|
|
26
|
+
# ActiveRecord's own databases.rake. That third step is hardwired: it ignores
|
|
27
|
+
# the dummy's `config.active_record.dump_schema_after_migration = false`,
|
|
28
|
+
# which is why plain `rake db:migrate` leaves no dump and this task does.
|
|
29
|
+
#
|
|
30
|
+
# The dump is stamped with the Rails version that wrote it
|
|
31
|
+
# (`ActiveRecord::Schema[8.1]`), and every later leg's `db:test:load_schema`
|
|
32
|
+
# prefers a schema.rb over the migrations — so one PostgreSQL run made the
|
|
33
|
+
# 7.2 Appraisal die with "Unknown migration version 8.1" until the file was
|
|
34
|
+
# deleted by hand. The dummy migrates from db/migrate and never from a dump,
|
|
35
|
+
# so the artifact has no reader: throw it away where it is made.
|
|
36
|
+
if Rake::Task.task_defined?("db:migrate:reset")
|
|
37
|
+
Rake::Task["db:migrate:reset"].enhance { rm_f "test/dummy/db/schema.rb" }
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
require "rake/testtask"
|
|
25
41
|
|
|
26
42
|
Rake::TestTask.new(:test) do |t|
|
|
@@ -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
|
|
@@ -55,9 +58,11 @@ module SupportDesk
|
|
|
55
58
|
# The form was submitted from a step that isn't the composer (a stale
|
|
56
59
|
# tab, a hand-rolled POST): re-render the step they are actually on.
|
|
57
60
|
render :new, status: :unprocessable_entity
|
|
58
|
-
rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable
|
|
59
|
-
# The subject passed the wizard's check and failed the model's
|
|
60
|
-
#
|
|
61
|
+
rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable, SupportDesk::NotARequester
|
|
62
|
+
# The subject passed the wizard's check and failed the model's, or this
|
|
63
|
+
# account stopped being able to write between the form and the submit.
|
|
64
|
+
# Same answer as a forged token: 404. "Not yours", "not there" and "not
|
|
65
|
+
# eligible" must look the same from outside.
|
|
61
66
|
raise ActiveRecord::RecordNotFound
|
|
62
67
|
end
|
|
63
68
|
|
|
@@ -68,6 +73,25 @@ module SupportDesk
|
|
|
68
73
|
redirect_to conversation_path_for(find_ticket)
|
|
69
74
|
end
|
|
70
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
|
+
|
|
71
95
|
private
|
|
72
96
|
|
|
73
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
|
|
@@ -75,13 +91,19 @@ module SupportDesk
|
|
|
75
91
|
within = SupportDesk.config.desk(desk_key).reply_within
|
|
76
92
|
return nil if within.nil?
|
|
77
93
|
|
|
78
|
-
t("support_desk.thread.promise", time: SupportDesk
|
|
94
|
+
t("support_desk.thread.promise", time: SupportDesk.humanize_duration(within))
|
|
79
95
|
end
|
|
80
96
|
|
|
81
97
|
# The status line under a case in the requester's list: who owes the next
|
|
82
98
|
# word, or that it's over.
|
|
83
99
|
def support_ticket_state(ticket)
|
|
84
100
|
return t("support_desk.tickets.state.closed") if ticket.closed?
|
|
101
|
+
# A conversation the desk started, before they have said anything back:
|
|
102
|
+
# "we replied" would be a lie about a thread they never opened, and
|
|
103
|
+
# "we're on it" is worse — there is nothing of theirs to be on.
|
|
104
|
+
if ticket.opened_by_support? && ticket.last_requester_message_at.nil?
|
|
105
|
+
return t("support_desk.tickets.state.opened_by_support")
|
|
106
|
+
end
|
|
85
107
|
|
|
86
108
|
ticket.awaiting_reply? ? t("support_desk.tickets.state.awaiting_reply") : t("support_desk.tickets.state.answered")
|
|
87
109
|
end
|
|
@@ -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
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
</div>
|
|
27
27
|
<% end %>
|
|
28
28
|
|
|
29
|
+
<div class="flex justify-between gap-3">
|
|
30
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.opened_by") %></dt>
|
|
31
|
+
<dd class="text-right"><%= card.opened_by_label %></dd>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
29
34
|
<div class="flex justify-between gap-3">
|
|
30
35
|
<dt class="text-slate-500"><%= t("support_desk.console.context.topic") %></dt>
|
|
31
36
|
<dd class="text-right"><%= card.topic_label %></dd>
|
|
@@ -44,8 +49,36 @@
|
|
|
44
49
|
<dd class="text-right"><%= value %></dd>
|
|
45
50
|
</div>
|
|
46
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 %>
|
|
47
64
|
</dl>
|
|
48
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
|
+
|
|
49
82
|
<% if card.subject_url.present? %>
|
|
50
83
|
<%= link_to t("support_desk.console.context.open_subject"), card.subject_url,
|
|
51
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 %>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<%# The compose form. Everything it renders is a draft the controller set
|
|
2
|
+
BEFORE it looked anything up, so a refusal comes back with the message
|
|
3
|
+
still in the box.
|
|
4
|
+
|
|
5
|
+
Three shapes for "who is this for", in order of how sure we are:
|
|
6
|
+
|
|
7
|
+
@requester a card — the page that opened this form knew
|
|
8
|
+
find_requester a text field — the host said how to look people up
|
|
9
|
+
neither a hint naming the setting, and no send button,
|
|
10
|
+
because a form that can't reach anybody is worse
|
|
11
|
+
than one that says so
|
|
12
|
+
|
|
13
|
+
Deliberately NOT promised here: who ends up holding the case. If this
|
|
14
|
+
person already has this conversation open, the message joins it as an
|
|
15
|
+
ordinary reply and the desk's reply policy decides the seat. %>
|
|
16
|
+
<%= form_with url: console_tickets_path(:open_conversation, desk: support_desk_record.key),
|
|
17
|
+
method: :post, html: { multipart: true }, class: "space-y-4" do %>
|
|
18
|
+
<section class="rounded-lg border border-slate-200 bg-white p-4">
|
|
19
|
+
<h2 class="text-sm font-semibold"><%= t("support_desk.console.new_conversation.requester") %></h2>
|
|
20
|
+
|
|
21
|
+
<% if @requester %>
|
|
22
|
+
<div class="mt-2 flex items-center justify-between gap-3">
|
|
23
|
+
<p class="text-sm font-medium"><%= Chats.display_name_for(@requester) %></p>
|
|
24
|
+
<%= hidden_field_tag :requester, @requester.to_global_id.to_s %>
|
|
25
|
+
</div>
|
|
26
|
+
<% elsif support_desk_record.config.find_requester %>
|
|
27
|
+
<%# Text, not an email field: the host's callback may look people up by
|
|
28
|
+
phone, handle or anything else they chose. %>
|
|
29
|
+
<%= text_field_tag :requester_query, @requester_query,
|
|
30
|
+
placeholder: t("support_desk.console.new_conversation.requester_query"),
|
|
31
|
+
class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<p class="mt-2 text-sm text-slate-500"><%= t("support_desk.console.new_conversation.requester_hint") %></p>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<% if @about %>
|
|
37
|
+
<p class="mt-3 text-sm text-slate-500">
|
|
38
|
+
<%= t("support_desk.console.new_conversation.about", subject: @about.support_label) %>
|
|
39
|
+
</p>
|
|
40
|
+
<%= hidden_field_tag :about, @about.to_global_id.to_s %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<section class="rounded-lg border border-slate-200 bg-white p-4">
|
|
45
|
+
<%# A subject brings its own topic with it, so the picker is only for
|
|
46
|
+
cases that are about nothing in particular. The supplied topic is
|
|
47
|
+
preserved either way. %>
|
|
48
|
+
<% if @about %>
|
|
49
|
+
<%= hidden_field_tag :topic, @topic %>
|
|
50
|
+
<% else %>
|
|
51
|
+
<label class="text-sm font-semibold" for="topic"><%= t("support_desk.console.new_conversation.topic") %></label>
|
|
52
|
+
<%= select_tag :topic,
|
|
53
|
+
options_for_select(support_conversation_topics.map { |topic| [ topic.full_label, topic.path ] }, @topic),
|
|
54
|
+
class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
55
|
+
<% end %>
|
|
56
|
+
|
|
57
|
+
<label class="mt-4 block text-sm font-semibold" for="body">
|
|
58
|
+
<%= t("support_desk.console.new_conversation.message") %>
|
|
59
|
+
</label>
|
|
60
|
+
<%= text_area_tag :body, @body, rows: 5, maxlength: Chats.config.max_message_length,
|
|
61
|
+
class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
62
|
+
|
|
63
|
+
<p class="mt-2 text-xs text-slate-500">
|
|
64
|
+
<%= t("support_desk.console.new_conversation.signed_as", agent: current_agent.support_agent_name) %>
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
<% if Chats.config.attachments %>
|
|
68
|
+
<div class="mt-3">
|
|
69
|
+
<%= file_field_tag "files[]", multiple: true, class: "text-xs text-slate-500" %>
|
|
70
|
+
<%# Browsers can't be handed a file input back. Say so rather than
|
|
71
|
+
letting somebody assume their attachment survived the 422. %>
|
|
72
|
+
<p class="mt-1 text-xs text-slate-500"><%= t("support_desk.console.errors.attachments_again") %></p>
|
|
73
|
+
</div>
|
|
74
|
+
<% end %>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<div class="flex items-center justify-between gap-3">
|
|
78
|
+
<%= link_to t("support_desk.console.new_conversation.cancel"), console_tickets_path,
|
|
79
|
+
class: "text-sm text-slate-500 underline underline-offset-2" %>
|
|
80
|
+
|
|
81
|
+
<% if support_conversation_sendable? && (@requester || support_desk_record.config.find_requester) %>
|
|
82
|
+
<%= submit_tag t("support_desk.console.new_conversation.send", desk: support_desk_record.name),
|
|
83
|
+
class: "rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
|
|
84
|
+
<% elsif flash[:alert].blank? %>
|
|
85
|
+
<p class="text-sm text-slate-500">
|
|
86
|
+
<%= support_conversation_available? ? t("support_desk.console.new_conversation.requester_hint") :
|
|
87
|
+
t("support_desk.console.errors.off_duty") %>
|
|
88
|
+
</p>
|
|
89
|
+
<% end %>
|
|
90
|
+
</div>
|
|
91
|
+
<% end %>
|
|
@@ -20,6 +20,31 @@
|
|
|
20
20
|
</span>
|
|
21
21
|
|
|
22
22
|
<span class="font-mono text-xs text-slate-400"><%= ticket.reference %></span>
|
|
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
|
+
|
|
41
|
+
<%# A case we started reads differently from one somebody asked for:
|
|
42
|
+
nobody is waiting on us in it until they answer. %>
|
|
43
|
+
<% if ticket.opened_by_support? %>
|
|
44
|
+
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
|
45
|
+
<%= t("support_desk.console.row.opened_by_support") %>
|
|
46
|
+
</span>
|
|
47
|
+
<% end %>
|
|
23
48
|
</div>
|
|
24
49
|
|
|
25
50
|
<p class="mt-0.5 text-sm text-slate-500">
|
|
@@ -46,6 +71,9 @@
|
|
|
46
71
|
</span>
|
|
47
72
|
<% end %>
|
|
48
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. %>
|
|
49
77
|
<span class="text-slate-500">
|
|
50
78
|
<%= ticket.assignee&.support_agent_name || t("support_desk.console.row.unassigned") %>
|
|
51
79
|
</span>
|