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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/.simplecov +53 -0
  4. data/Appraisals +18 -0
  5. data/CHANGELOG.md +135 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +469 -0
  8. data/Rakefile +55 -0
  9. data/app/assets/stylesheets/support_desk.css +242 -0
  10. data/app/controllers/support_desk/application_controller.rb +92 -0
  11. data/app/controllers/support_desk/console/application_controller.rb +18 -0
  12. data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
  13. data/app/controllers/support_desk/tickets_controller.rb +119 -0
  14. data/app/helpers/support_desk/engine_helper.rb +196 -0
  15. data/app/views/chats/slots/_inbox_top.html.erb +31 -0
  16. data/app/views/chats/slots/_locked_composer.html.erb +18 -0
  17. data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
  18. data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
  19. data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
  20. data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
  21. data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
  22. data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
  23. data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
  24. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
  25. data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
  26. data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
  27. data/app/views/support_desk/console/tickets/index.html.erb +37 -0
  28. data/app/views/support_desk/console/tickets/show.html.erb +45 -0
  29. data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
  30. data/app/views/support_desk/tickets/_door.html.erb +12 -0
  31. data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
  32. data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
  33. data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
  34. data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
  35. data/app/views/support_desk/tickets/_write.html.erb +56 -0
  36. data/app/views/support_desk/tickets/index.html.erb +53 -0
  37. data/app/views/support_desk/tickets/new.html.erb +14 -0
  38. data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
  39. data/config/console_routes.rb +16 -0
  40. data/config/locales/support_desk.console.en.yml +93 -0
  41. data/config/locales/support_desk.console.es.yml +93 -0
  42. data/config/locales/support_desk.en.yml +79 -0
  43. data/config/locales/support_desk.es.yml +84 -0
  44. data/config/routes.rb +24 -0
  45. data/context7.json +4 -0
  46. data/gemfiles/rails_7.2.gemfile +34 -0
  47. data/gemfiles/rails_8.0.gemfile +34 -0
  48. data/gemfiles/rails_8.1.gemfile +34 -0
  49. data/lib/generators/support_desk/console_generator.rb +94 -0
  50. data/lib/generators/support_desk/install_generator.rb +86 -0
  51. data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
  52. data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
  53. data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
  54. data/lib/generators/support_desk/templates/initializer.rb +186 -0
  55. data/lib/generators/support_desk/views_generator.rb +50 -0
  56. data/lib/support_desk/configuration.rb +675 -0
  57. data/lib/support_desk/console.rb +487 -0
  58. data/lib/support_desk/console_engine.rb +63 -0
  59. data/lib/support_desk/console_routes.rb +107 -0
  60. data/lib/support_desk/context_card.rb +90 -0
  61. data/lib/support_desk/current.rb +26 -0
  62. data/lib/support_desk/doctor.rb +220 -0
  63. data/lib/support_desk/engine.rb +141 -0
  64. data/lib/support_desk/errors.rb +49 -0
  65. data/lib/support_desk/events.rb +122 -0
  66. data/lib/support_desk/macros.rb +73 -0
  67. data/lib/support_desk/models/application_record.rb +11 -0
  68. data/lib/support_desk/models/assignment.rb +83 -0
  69. data/lib/support_desk/models/concerns/agent.rb +79 -0
  70. data/lib/support_desk/models/concerns/requester.rb +71 -0
  71. data/lib/support_desk/models/concerns/supportable.rb +88 -0
  72. data/lib/support_desk/models/desk.rb +101 -0
  73. data/lib/support_desk/models/event.rb +72 -0
  74. data/lib/support_desk/models/ticket.rb +1124 -0
  75. data/lib/support_desk/queue.rb +171 -0
  76. data/lib/support_desk/summary.rb +70 -0
  77. data/lib/support_desk/test_helpers.rb +137 -0
  78. data/lib/support_desk/timeline.rb +104 -0
  79. data/lib/support_desk/topic.rb +290 -0
  80. data/lib/support_desk/topic_tree.rb +214 -0
  81. data/lib/support_desk/version.rb +5 -0
  82. data/lib/support_desk/wizard.rb +392 -0
  83. data/lib/support_desk.rb +288 -0
  84. 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>