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,26 @@
1
+ <%# The conversation, exactly as the requester has it — this IS their thread,
2
+ not a copy of it.
3
+
4
+ We lay the bubbles out here rather than rendering chats' own
5
+ `chats/messages/_message`: that partial is built for the requester's
6
+ thread (its CSS, its long-press menu, its reaction buttons, its Stimulus
7
+ controller), and none of that belongs in an admin console. What we DO
8
+ reuse is chats' view API — `chats_messager_avatar` and
9
+ `chats_message_signature` are mixed into every host's ActionView — so
10
+ avatars and signatures look the same on both sides of the desk. %>
11
+ <section class="rounded-lg border border-slate-200 bg-white">
12
+ <h2 class="border-b border-slate-200 px-4 py-3 text-sm font-semibold">
13
+ <%= t("support_desk.console.transcript.title") %>
14
+ </h2>
15
+
16
+ <div class="max-h-[32rem] space-y-3 overflow-y-auto p-4">
17
+ <% messages = support_transcript(ticket) %>
18
+ <% if messages.empty? %>
19
+ <p class="text-sm text-slate-500"><%= t("support_desk.console.transcript.empty") %></p>
20
+ <% else %>
21
+ <% messages.each do |message| %>
22
+ <%= render "message", message: message, ticket: ticket %>
23
+ <% end %>
24
+ <% end %>
25
+ </div>
26
+ </section>
@@ -0,0 +1,37 @@
1
+ <%# The queue.
2
+
3
+ Everything here comes from Layer 1 (SupportDesk::Queue) and the path
4
+ helpers the console concern derives from THIS controller's own route —
5
+ which is why the same file renders under the mounted ConsoleEngine and
6
+ under a host's /madmin namespace without a line changing.
7
+
8
+ `turbo_stream_from ..., :queue` is half of the realtime contract: the
9
+ gem broadcasts a Turbo 8 refresh to this stream on every message and
10
+ every transition, so the counts and the rows re-render themselves. %>
11
+ <%= turbo_stream_from support_desk_record, :queue %>
12
+
13
+ <div class="mx-auto max-w-6xl px-4 py-6 text-slate-900">
14
+ <header class="mb-5 flex flex-wrap items-center justify-between gap-3">
15
+ <div>
16
+ <h1 class="text-xl font-semibold"><%= t("support_desk.console.queue.title") %></h1>
17
+ <p class="text-sm text-slate-500"><%= support_desk_record.name %></p>
18
+ </div>
19
+
20
+ <%= link_to t("support_desk.console.actions.next"), console_tickets_path(:next),
21
+ class: "inline-flex items-center rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
22
+ </header>
23
+
24
+ <%= render "tabs", queue: @queue, scope: @scope %>
25
+
26
+ <% if @tickets.empty? %>
27
+ <p class="rounded-lg border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
28
+ <%= t("support_desk.console.queue.empty") %>
29
+ </p>
30
+ <% else %>
31
+ <ul class="divide-y divide-slate-200 overflow-hidden rounded-lg border border-slate-200 bg-white">
32
+ <% @tickets.each do |ticket| %>
33
+ <%= render "ticket_row", ticket: ticket %>
34
+ <% end %>
35
+ </ul>
36
+ <% end %>
37
+ </div>
@@ -0,0 +1,45 @@
1
+ <%# One case.
2
+
3
+ Left: the transcript and the composer — what the requester sees, plus
4
+ the internal half they don't. Right: the context card (what this is
5
+ about and what state it is in) and the holder line.
6
+
7
+ `turbo_stream_from @ticket, :console` is the other half of the realtime
8
+ contract: every message and every transition broadcasts a Turbo 8
9
+ refresh here, so two agents looking at the same case see the same case. %>
10
+ <%= turbo_stream_from @ticket, :console %>
11
+
12
+ <div class="mx-auto max-w-6xl px-4 py-6 text-slate-900">
13
+ <nav class="mb-4 text-sm">
14
+ <%= link_to t("support_desk.console.actions.back"), console_tickets_path, class: "text-slate-500 hover:text-slate-900" %>
15
+ </nav>
16
+
17
+ <header class="mb-5 flex flex-wrap items-start justify-between gap-3">
18
+ <div>
19
+ <h1 class="text-xl font-semibold"><%= @ticket.label %></h1>
20
+ <p class="mt-0.5 text-sm text-slate-500">
21
+ <span class="font-mono"><%= @ticket.reference %></span> ·
22
+ <%= t("support_desk.status.#{@ticket.status}") %> ·
23
+ <%= @ticket.summary.state %>
24
+ <% if @ticket.waiting_for %>
25
+ (<%= @ticket.summary.waiting %>)
26
+ <% end %>
27
+ </p>
28
+ </div>
29
+
30
+ <%= render "actions", ticket: @ticket, actions: @actions %>
31
+ </header>
32
+
33
+ <div class="grid gap-6 lg:grid-cols-3">
34
+ <div class="space-y-4 lg:col-span-2">
35
+ <%= render "transcript", ticket: @ticket %>
36
+ <%= render "composer", ticket: @ticket, actions: @actions %>
37
+ </div>
38
+
39
+ <aside class="space-y-4">
40
+ <%= render "context_card", card: @context_card %>
41
+ <%= render "assignment", ticket: @ticket, actions: @actions %>
42
+ <%= render "timeline", ticket: @ticket %>
43
+ </aside>
44
+ </div>
45
+ </div>
@@ -0,0 +1,14 @@
1
+ <%# What the case is about, above the composer, so nobody has to describe in
2
+ words which order they mean.
3
+
4
+ Deliberately only the label and the status pill. `support_context` is
5
+ documented on SupportDesk::Supportable as "rendered to agents only — you
6
+ decide what belongs there", and hosts fill it with things they chose for
7
+ an agent's eyes (internal state, risk flags, an admin URL). A requester
8
+ screen must not be the place that quietly publishes them. %>
9
+ <div class="support-desk-card">
10
+ <span class="support-desk-card__label"><%= subject.support_label %></span>
11
+ <% if subject.support_status.present? %>
12
+ <span class="support-desk-card__status"><%= subject.support_status %></span>
13
+ <% end %>
14
+ </div>
@@ -0,0 +1,12 @@
1
+ <%# "Abrir una nueva conversación" — the way in, always above the list.
2
+ A support screen whose only entry point is an existing case is a screen
3
+ nobody can start from. %>
4
+ <%= link_to support_desk_routes.new_ticket_path, class: "support-desk-door" do %>
5
+ <span class="support-desk-door__icon" aria-hidden="true">+</span>
6
+ <span class="support-desk-door__body">
7
+ <span class="support-desk-door__title"><%= t("support_desk.doors.new_conversation") %></span>
8
+ <% if (promise = support_reply_promise) %>
9
+ <span class="support-desk-door__hint"><%= promise %></span>
10
+ <% end %>
11
+ </span>
12
+ <% end %>
@@ -0,0 +1,55 @@
1
+ <%# Step 2 — "¿Sobre cuál?" The records this topic attaches, each with its
2
+ own label and status pill.
3
+
4
+ Something they already have an open case about is MARKED, never hidden:
5
+ hiding it would leave them hunting for a conversation they can't find,
6
+ so the row links straight into it instead. %>
7
+ <%= render "support_desk/tickets/wizard_header", wizard: wizard, error: error %>
8
+
9
+ <% if wizard.choices.any? %>
10
+ <ul class="support-desk-choices">
11
+ <% wizard.choices.each do |record| %>
12
+ <% open_ticket = wizard.open_ticket_about(record) %>
13
+ <li>
14
+ <% if open_ticket %>
15
+ <%= link_to support_thread_path(open_ticket),
16
+ class: "support-desk-choice support-desk-choice--taken",
17
+ data: { turbo_frame: "_top" } do %>
18
+ <span class="support-desk-choice__label"><%= record.support_label %></span>
19
+ <span class="support-desk-choice__hint"><%= t("support_desk.wizard.already_open") %> ›</span>
20
+ <% end %>
21
+ <% else %>
22
+ <%= link_to support_desk_routes.new_ticket_path(topic: wizard.topic.path,
23
+ about: SupportDesk::Wizard.sign_subject(record)),
24
+ class: "support-desk-choice" do %>
25
+ <span class="support-desk-choice__label"><%= record.support_label %></span>
26
+ <% if record.support_status.present? %>
27
+ <span class="support-desk-choice__status"><%= record.support_status %></span>
28
+ <% end %>
29
+ <span class="support-desk-choice__chevron" aria-hidden="true">›</span>
30
+ <% end %>
31
+ <% end %>
32
+ </li>
33
+ <% end %>
34
+ </ul>
35
+ <% else %>
36
+ <%# `subject: :required` with nothing to require. The wizard skips this
37
+ step entirely when the topic is happy without a subject, so reaching
38
+ here means the topic insists — and the honest answer is that there is
39
+ nothing to pick. %>
40
+ <p class="support-desk-empty"><%= t("support_desk.wizard.nothing_to_pick") %></p>
41
+
42
+ <%# A dead end whose only link is "Back" is a dead end. The declared way
43
+ out takes them to a composer they can actually write in. %>
44
+ <% if (exit_leaf = wizard.free_form_exit) %>
45
+ <%= link_to t("support_desk.wizard.write_anyway"),
46
+ support_desk_routes.new_ticket_path(topic: exit_leaf.path),
47
+ class: "support-desk-choice support-desk-choice--none" %>
48
+ <% end %>
49
+ <% end %>
50
+
51
+ <% if wizard.subject_optional? %>
52
+ <%= link_to t("support_desk.wizard.none_of_these"),
53
+ support_desk_routes.new_ticket_path(topic: wizard.topic.path, no_subject: 1),
54
+ class: "support-desk-choice support-desk-choice--none" %>
55
+ <% end %>
@@ -0,0 +1,30 @@
1
+ <%# Step 1 — "¿Con qué necesitas ayuda?" One level of the tree at a time: a
2
+ branch drills in place (same frame, new URL), a leaf ends the step.
3
+ Retired topics and anything behind an `only:` never reach this list —
4
+ the tree filtered them out. %>
5
+ <%= render "support_desk/tickets/wizard_header", wizard: wizard, error: error %>
6
+
7
+ <% if wizard.choices.any? %>
8
+ <ul class="support-desk-choices">
9
+ <% wizard.choices.each do |topic| %>
10
+ <li>
11
+ <%= link_to support_desk_routes.new_ticket_path(topic: topic.path), class: "support-desk-choice" do %>
12
+ <% if topic.icon %>
13
+ <span class="support-desk-choice__icon" aria-hidden="true"><%= topic.icon %></span>
14
+ <% end %>
15
+ <span class="support-desk-choice__label"><%= topic.label %></span>
16
+ <span class="support-desk-choice__chevron" aria-hidden="true">›</span>
17
+ <% end %>
18
+ </li>
19
+ <% end %>
20
+ </ul>
21
+ <% else %>
22
+ <%# Only reachable on a desk with no topics AND no free-form leaf — which
23
+ the gem warns about at boot. Say so plainly instead of rendering a
24
+ question with no answers. %>
25
+ <div class="chats-empty">
26
+ <p class="chats-empty__title"><%= t("support_desk.wizard.no_topics") %></p>
27
+ <%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
28
+ class: "support-desk-button", data: { turbo_frame: "_top" } %>
29
+ </div>
30
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <%# One case in the requester's list, shaped exactly like a chats inbox row:
2
+ the desk's avatar, what the case is about, the last thing said in it, and
3
+ the unread badge. Tapping it opens the conversation itself — the engine's
4
+ own /tickets/:id URL exists for links from outside (an email, a push),
5
+ and there is no reason to pay for its redirect here. %>
6
+ <% unread = unread_counts.fetch(ticket.conversation_id, 0) %>
7
+ <li class="chats-row <%= "chats-row--unread" if unread.positive? %>">
8
+ <%= link_to support_thread_path(ticket), class: "chats-row__link" do %>
9
+ <%= chats_messager_avatar(ticket.desk) %>
10
+
11
+ <span class="chats-row__body">
12
+ <span class="chats-row__top">
13
+ <span class="chats-row__title"><%= ticket.label %></span>
14
+ <time class="chats-row__time" datetime="<%= (ticket.conversation&.last_message_at || ticket.opened_at)&.iso8601 %>">
15
+ <%= chats_timestamp(ticket.conversation&.last_message_at || ticket.opened_at) %>
16
+ </time>
17
+ </span>
18
+
19
+ <span class="chats-row__subject"><%= support_ticket_state(ticket) %></span>
20
+
21
+ <span class="chats-row__bottom">
22
+ <span class="chats-row__preview">
23
+ <%= ticket.conversation ? chats_preview_for(ticket.conversation, current_requester) : "" %>
24
+ </span>
25
+ <% if unread.positive? %>
26
+ <span class="chats-badge"><%= unread > 99 ? "99+" : unread %></span>
27
+ <% end %>
28
+ </span>
29
+ </span>
30
+ <% end %>
31
+ </li>
@@ -0,0 +1,23 @@
1
+ <%# The top of every wizard frame: the way back, the question, and anything
2
+ that went wrong. Inside the frame, not above it, so one frame render
3
+ updates all three. %>
4
+ <header class="support-desk-wizard__header">
5
+ <% if (back = wizard.back) %>
6
+ <%= link_to t("support_desk.wizard.back"),
7
+ support_desk_routes.new_ticket_path(**back),
8
+ class: "support-desk-wizard__back" %>
9
+ <% else %>
10
+ <%= link_to t("support_desk.wizard.back"),
11
+ support_desk_routes.root_path,
12
+ class: "support-desk-wizard__back",
13
+ data: { turbo_frame: "_top" } %>
14
+ <% end %>
15
+
16
+ <% if (ask = local_assigns[:ask] || wizard.ask) %>
17
+ <h1 class="support-desk-wizard__ask"><%= ask %></h1>
18
+ <% end %>
19
+ </header>
20
+
21
+ <% if error.present? %>
22
+ <p class="support-desk-error" role="alert"><%= error %></p>
23
+ <% end %>
@@ -0,0 +1,56 @@
1
+ <%# Step 3 — the composer. Context card, prefill, attachments, and the
2
+ promise, which is the same `config.reply_within` the SLA breaches on.
3
+
4
+ The form targets `_top`: it POSTs to /tickets and the answer is a
5
+ redirect into the conversation, which must replace the page, not land
6
+ inside the wizard frame. %>
7
+ <%= render "support_desk/tickets/wizard_header", wizard: wizard, error: error,
8
+ ask: t("support_desk.wizard.write") %>
9
+
10
+ <% if (existing = wizard.existing_ticket) %>
11
+ <%# They already have a case open about exactly this. Opening a second one
12
+ would split the conversation in two and leave the agent reading half a
13
+ story, so we hand them the one they have. %>
14
+ <div class="support-desk-notice">
15
+ <p class="support-desk-notice__title"><%= t("support_desk.wizard.already_open") %></p>
16
+ <%= link_to t("support_desk.doors.existing"), support_thread_path(existing),
17
+ class: "support-desk-button", data: { turbo_frame: "_top" } %>
18
+ </div>
19
+ <% else %>
20
+ <%= render "support_desk/tickets/context_card", subject: wizard.subject if wizard.subject %>
21
+
22
+ <%= form_with url: support_desk_routes.tickets_path,
23
+ method: :post,
24
+ class: "support-desk-form",
25
+ data: { turbo_frame: "_top" } do %>
26
+ <% wizard.state_params.each do |name, value| %>
27
+ <%= hidden_field_tag name, value %>
28
+ <% end %>
29
+
30
+ <%# What they typed wins over the prefill: this template also renders the
31
+ 422 after a refused submit, and losing five paragraphs to a validation
32
+ message is the rudest thing a form can do. %>
33
+ <%= text_area_tag :message, params[:message].presence || wizard.prefill,
34
+ rows: 6,
35
+ class: "support-desk-form__input",
36
+ placeholder: wizard.placeholder,
37
+ autocomplete: "off" %>
38
+
39
+ <% if Chats.config.attachments %>
40
+ <label class="support-desk-form__attach">
41
+ <%= file_field_tag "files[]", multiple: true,
42
+ accept: (Chats.config.attachments == :images ? "image/*" : nil) %>
43
+ </label>
44
+ <% end %>
45
+
46
+ <% if wizard.promise %>
47
+ <p class="support-desk-form__promise"><%= wizard.promise %></p>
48
+ <% end %>
49
+
50
+ <%# turbo_submits_with disables the button for the length of the submit.
51
+ `Ticket.open!` makes the TICKET idempotent, but two taps still post
52
+ two identical opening messages into it. %>
53
+ <%= submit_tag t("support_desk.wizard.send"), class: "support-desk-button",
54
+ data: { turbo_submits_with: t("support_desk.wizard.sending") } %>
55
+ <% end %>
56
+ <% end %>
@@ -0,0 +1,53 @@
1
+ <%# The requester's support list: their open cases as chats rows (desk
2
+ avatar, ticket label, who owes the next word), the door into a new one,
3
+ and everything closed folded away underneath.
4
+
5
+ It renders chats' own row classes on purpose — a support case IS a
6
+ conversation, and it should look like one everywhere the requester meets
7
+ it. Both stylesheets, because both sets of classes are on this page. %>
8
+ <%= chats_styles %>
9
+ <%= support_desk_styles %>
10
+
11
+ <div class="chats chats-inbox support-desk">
12
+ <header class="chats-inbox__header">
13
+ <h1 class="chats-inbox__title"><%= t("support_desk.tickets.title") %></h1>
14
+ </header>
15
+
16
+ <%= render "support_desk/tickets/door" %>
17
+
18
+ <% if @open_tickets.any? %>
19
+ <ul class="chats-inbox__list">
20
+ <%= render partial: "support_desk/tickets/ticket_row",
21
+ collection: @open_tickets,
22
+ as: :ticket,
23
+ locals: { unread_counts: @unread_counts } %>
24
+ </ul>
25
+ <% else %>
26
+ <div class="chats-empty">
27
+ <span class="chats-empty__icon" aria-hidden="true">💬</span>
28
+ <p class="chats-empty__title"><%= t("support_desk.tickets.empty_title") %></p>
29
+ <p class="chats-empty__hint"><%= t("support_desk.tickets.empty_hint") %></p>
30
+ </div>
31
+ <% end %>
32
+
33
+ <%# Closed cases are history: collapsed while anything is live, open when
34
+ the list would otherwise look empty. %>
35
+ <% if @closed_tickets.any? %>
36
+ <%= tag.details class: "support-desk-closed", open: @open_tickets.empty? do %>
37
+ <summary class="support-desk-closed__summary">
38
+ <%= t("support_desk.tickets.closed_section", count: @closed_count) %>
39
+ </summary>
40
+ <% if @closed_count > @closed_tickets.size %>
41
+ <p class="support-desk-closed__capped">
42
+ <%= t("support_desk.tickets.closed_capped", count: @closed_tickets.size) %>
43
+ </p>
44
+ <% end %>
45
+ <ul class="chats-inbox__list">
46
+ <%= render partial: "support_desk/tickets/ticket_row",
47
+ collection: @closed_tickets,
48
+ as: :ticket,
49
+ locals: { unread_counts: @unread_counts } %>
50
+ </ul>
51
+ <% end %>
52
+ <% end %>
53
+ </div>
@@ -0,0 +1,14 @@
1
+ <%# The wizard: one URL, three frames.
2
+
3
+ `data-turbo-action="advance"` is what keeps the promise the native apps
4
+ depend on — every step is a REAL URL, pushed into history, bookmarkable
5
+ and deep-linkable, even though only the frame re-renders. Nothing here
6
+ calls history.back(); each step renders its own back link. %>
7
+ <%= chats_styles %>
8
+ <%= support_desk_styles %>
9
+
10
+ <div class="chats support-desk">
11
+ <%= turbo_frame_tag "support_wizard", data: { turbo_action: "advance" } do %>
12
+ <%= render support_wizard_partial(@wizard), wizard: @wizard, error: @error %>
13
+ <% end %>
14
+ </div>
@@ -0,0 +1,40 @@
1
+ <%# The wall, when somebody is at the cap or has opened too many cases too
2
+ fast. It is a WALL WITH A DOOR IN IT: the cases they already have, so
3
+ "you have too many open conversations" always comes with the place to go
4
+ and write. Never a 500, never a silent failure. %>
5
+ <%= chats_styles %>
6
+ <%= support_desk_styles %>
7
+
8
+ <div class="chats chats-inbox support-desk">
9
+ <header class="chats-inbox__header">
10
+ <h1 class="chats-inbox__title"><%= t("support_desk.limits.#{@limit_reason}_title") %></h1>
11
+ </header>
12
+
13
+ <p class="support-desk-notice__title">
14
+ <%= t("support_desk.limits.#{@limit_reason}", count: @open_tickets.size) %>
15
+ </p>
16
+
17
+ <% if @open_tickets.any? %>
18
+ <ul class="chats-inbox__list">
19
+ <%= render partial: "support_desk/tickets/ticket_row",
20
+ collection: @open_tickets,
21
+ as: :ticket,
22
+ locals: { unread_counts: @unread_counts } %>
23
+ </ul>
24
+ <% end %>
25
+
26
+ <%# Whatever they had typed comes with them. The wall sends them into a
27
+ conversation they already have, and the words they just wrote are not
28
+ ours to throw away — kept on the page to copy rather than pushed
29
+ through a URL, where it would land in logs and browser history. %>
30
+ <% if params[:message].present? %>
31
+ <div class="support-desk-form">
32
+ <p class="support-desk-form__promise"><%= t("support_desk.limits.your_message") %></p>
33
+ <%= text_area_tag :message, params[:message], rows: 6, readonly: true,
34
+ class: "support-desk-form__input", id: "support_desk_kept_message" %>
35
+ </div>
36
+ <% end %>
37
+
38
+ <%= link_to t("support_desk.wizard.back_to_list"), support_desk_routes.root_path,
39
+ class: "support-desk-button" %>
40
+ </div>
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The turnkey console:
4
+ #
5
+ # mount SupportDesk::ConsoleEngine => "/admin/support"
6
+ #
7
+ # `path: ""` keeps the URLs short under the mount point: the queue is
8
+ # /admin/support, a case is /admin/support/:id, and every verb is a POST to
9
+ # /admin/support/:id/<verb>. The member and collection routes come from the
10
+ # SAME `:support_console` concern a host writes in their own routes file —
11
+ # the turnkey console gets no private API.
12
+ SupportDesk::ConsoleEngine.routes.draw do
13
+ resources :tickets, path: "", only: %i[index show], concerns: :support_console
14
+
15
+ root to: "tickets#index"
16
+ end
@@ -0,0 +1,93 @@
1
+ en:
2
+ support_desk:
3
+ console:
4
+ nav:
5
+ title: "Support"
6
+ queue:
7
+ title: "Support queue"
8
+ tabs_label: "Queue tabs"
9
+ empty: "Nothing here right now."
10
+ row:
11
+ waiting: "%{duration}"
12
+ unassigned: "Unassigned"
13
+ context:
14
+ title: "Context"
15
+ requester: "Requester"
16
+ open_tickets: "Open cases"
17
+ member_since: "Member since"
18
+ topic: "Topic"
19
+ subject_status: "Status"
20
+ open_subject: "Open in admin"
21
+ transcript:
22
+ title: "Conversation"
23
+ empty: "Nothing has been said yet."
24
+ composer:
25
+ reply: "Reply"
26
+ note: "Internal note"
27
+ reply_placeholder: "Write your answer"
28
+ note_placeholder: "Something only the desk should know"
29
+ note_hint: "Notes never leave the desk."
30
+ closed: "This case is closed. Reopen it to answer."
31
+ assignee_only: "%{agent} is handling this case, and this desk only lets the assignee reply."
32
+ assignment:
33
+ title: "Who has it"
34
+ held_by: "Held by %{agent}"
35
+ unassigned: "Nobody has picked this up yet"
36
+ choose_agent: "Choose an agent"
37
+ nobody: "nobody"
38
+ note_placeholder: "Note for whoever picks it up"
39
+ timeline:
40
+ title: "Timeline"
41
+ system: "System"
42
+ kinds:
43
+ message: "Message"
44
+ opened: "Opened"
45
+ assigned: "Assigned"
46
+ handed_off: "Handed off"
47
+ released: "Released"
48
+ drop_in: "Drop-in reply"
49
+ closed: "Closed"
50
+ reopened: "Reopened"
51
+ topic_changed: "Topic changed"
52
+ subject_attached: "Subject attached"
53
+ note: "Internal note"
54
+ actions:
55
+ back: "Back to the queue"
56
+ next: "Next case"
57
+ reply: "Send"
58
+ note: "Save note"
59
+ take: "Take"
60
+ assign: "Assign"
61
+ hand_off: "Hand off"
62
+ release: "Release"
63
+ close: "Close"
64
+ reopen: "Reopen"
65
+ change_topic: "Refile"
66
+ flashes:
67
+ replied: "Answer sent."
68
+ taken: "This case is yours."
69
+ assigned: "Assigned to %{agent}."
70
+ handed_off: "Handed off to %{agent}."
71
+ released: "Back in the unassigned pile."
72
+ closed: "Case closed."
73
+ reopened: "Case reopened."
74
+ noted: "Note saved."
75
+ topic_changed: "Refiled."
76
+ queue_empty: "Nothing is waiting for you."
77
+ errors:
78
+ forbidden: "You don't have access to the support console."
79
+ not_allowed: "You can't do that on this case."
80
+ not_the_assignee: "%{holder} is handling this case. Use Assign if you need to take it anyway."
81
+ not_an_agent: "That person can't answer tickets on this desk."
82
+ invalid_transition: "That can't happen from where this case is now."
83
+ locked: "This conversation is closed for writing."
84
+ unknown_topic: "That topic isn't in this desk's tree."
85
+ unknown_agent: "Choose an agent from this desk."
86
+ blank_message: "Write something before sending."
87
+ blank_note: "A note needs something to say."
88
+ blank_topic: "Choose a topic."
89
+ closed_case: "This case is closed. Reopen it first."
90
+ take_it_first: "Take this case first — this desk only lets the assignee reply."
91
+ held_by_somebody_else: "%{holder} is handling this case."
92
+ unavailable_action: "You can't do that on this case right now."
93
+ generic: "That didn't work."
@@ -0,0 +1,93 @@
1
+ es:
2
+ support_desk:
3
+ console:
4
+ nav:
5
+ title: "Soporte"
6
+ queue:
7
+ title: "Cola de soporte"
8
+ tabs_label: "Pestañas de la cola"
9
+ empty: "Aquí no hay nada ahora mismo."
10
+ row:
11
+ waiting: "%{duration}"
12
+ unassigned: "Sin asignar"
13
+ context:
14
+ title: "Contexto"
15
+ requester: "Cliente"
16
+ open_tickets: "Casos abiertos"
17
+ member_since: "Cliente desde"
18
+ topic: "Tema"
19
+ subject_status: "Estado"
20
+ open_subject: "Abrir en el admin"
21
+ transcript:
22
+ title: "Conversación"
23
+ empty: "Todavía no se ha dicho nada."
24
+ composer:
25
+ reply: "Responder"
26
+ note: "Nota interna"
27
+ reply_placeholder: "Escribe tu respuesta"
28
+ note_placeholder: "Algo que solo debe saber el equipo"
29
+ note_hint: "Las notas nunca salen del equipo."
30
+ closed: "Este caso está cerrado. Vuelve a abrirlo para responder."
31
+ assignee_only: "%{agent} lleva este caso y en esta mesa solo responde quien lo tiene."
32
+ assignment:
33
+ title: "Quién lo lleva"
34
+ held_by: "Lo lleva %{agent}"
35
+ unassigned: "Todavía no lo ha cogido nadie"
36
+ choose_agent: "Elige a alguien"
37
+ nobody: "nadie"
38
+ note_placeholder: "Nota para quien lo coja"
39
+ timeline:
40
+ title: "Historial"
41
+ system: "Sistema"
42
+ kinds:
43
+ message: "Mensaje"
44
+ opened: "Abierto"
45
+ assigned: "Asignado"
46
+ handed_off: "Pasado a otra persona"
47
+ released: "Liberado"
48
+ drop_in: "Respuesta de otra persona"
49
+ closed: "Cerrado"
50
+ reopened: "Reabierto"
51
+ topic_changed: "Cambio de tema"
52
+ subject_attached: "Asunto vinculado"
53
+ note: "Nota interna"
54
+ actions:
55
+ back: "Volver a la cola"
56
+ next: "Siguiente caso"
57
+ reply: "Enviar"
58
+ note: "Guardar nota"
59
+ take: "Tomar"
60
+ assign: "Asignar"
61
+ hand_off: "Pasar a"
62
+ release: "Soltar"
63
+ close: "Cerrar"
64
+ reopen: "Reabrir"
65
+ change_topic: "Cambiar tema"
66
+ flashes:
67
+ replied: "Respuesta enviada."
68
+ taken: "El caso es tuyo."
69
+ assigned: "Asignado a %{agent}."
70
+ handed_off: "Pasado a %{agent}."
71
+ released: "De vuelta a la cola sin asignar."
72
+ closed: "Caso cerrado."
73
+ reopened: "Caso reabierto."
74
+ noted: "Nota guardada."
75
+ topic_changed: "Tema cambiado."
76
+ queue_empty: "No te espera nada."
77
+ errors:
78
+ forbidden: "No tienes acceso a la consola de soporte."
79
+ not_allowed: "No puedes hacer eso en este caso."
80
+ not_the_assignee: "Lo lleva %{holder}. Usa Asignar si necesitas quitárselo."
81
+ not_an_agent: "Esa persona no puede responder casos en esta mesa."
82
+ invalid_transition: "Eso no puede pasar desde donde está el caso ahora."
83
+ locked: "Esta conversación está cerrada."
84
+ unknown_topic: "Ese tema no está en el árbol de esta mesa."
85
+ unknown_agent: "Elige a alguien de esta mesa."
86
+ blank_message: "Escribe algo antes de enviar."
87
+ blank_note: "Una nota necesita decir algo."
88
+ blank_topic: "Elige un tema."
89
+ closed_case: "Este caso está cerrado. Vuelve a abrirlo primero."
90
+ take_it_first: "Toma el caso antes de responder: en esta mesa solo responde quien lo lleva."
91
+ held_by_somebody_else: "Lo lleva %{holder}."
92
+ unavailable_action: "Ahora mismo no puedes hacer eso en este caso."
93
+ generic: "No ha funcionado."