@escalated-dev/escalated 0.7.1 → 0.9.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 (117) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +380 -357
  3. package/package.json +85 -81
  4. package/src/components/ActiveChatsPanel.vue +246 -246
  5. package/src/components/AdminDashboard.stories.js +1423 -1423
  6. package/src/components/AttachmentList.vue +65 -65
  7. package/src/components/ChatActionBar.vue +226 -226
  8. package/src/components/ChatBubble.vue +162 -162
  9. package/src/components/ChatComposer.vue +308 -308
  10. package/src/components/ChatQueue.vue +222 -222
  11. package/src/components/ChatThread.vue +197 -197
  12. package/src/components/CustomTicketActions.stories.js +71 -0
  13. package/src/components/CustomTicketActions.vue +101 -0
  14. package/src/components/EscalatedLayout.vue +619 -598
  15. package/src/components/FollowButton.vue +20 -1
  16. package/src/components/MentionDropdown.vue +154 -154
  17. package/src/components/MetricComparison.vue +89 -89
  18. package/src/components/PercentileChart.vue +67 -67
  19. package/src/components/PeriodSelector.vue +98 -98
  20. package/src/components/PresenceIndicator.vue +228 -213
  21. package/src/components/ReplyComposer.vue +336 -336
  22. package/src/components/ReplyThread.vue +153 -153
  23. package/src/components/ReportChart.vue +395 -395
  24. package/src/components/ReportExportButton.vue +108 -108
  25. package/src/components/SaveViewDialog.vue +172 -172
  26. package/src/components/SavedViewSidebar.vue +208 -208
  27. package/src/components/SkillTagManager.vue +29 -22
  28. package/src/components/SnoozeButton.vue +247 -247
  29. package/src/components/TicketFilters.vue +270 -270
  30. package/src/components/TicketList.vue +655 -655
  31. package/src/components/TicketSidebar.vue +4 -0
  32. package/src/components/TicketSplitDialog.vue +98 -98
  33. package/src/components/TicketSubjects.stories.js +63 -0
  34. package/src/components/TicketSubjects.vue +107 -0
  35. package/src/components/TwoFactorSetup.vue +1 -37
  36. package/src/components/VariableTokenInput.vue +111 -111
  37. package/src/components/WorkflowActionConfig.vue +287 -287
  38. package/src/components/WorkflowActionList.vue +350 -350
  39. package/src/components/WorkflowConditionBuilder.vue +265 -265
  40. package/src/components/WorkflowTriggerSelector.vue +125 -125
  41. package/src/components/admin/newsletters/AnalyticsTiles.stories.js +14 -0
  42. package/src/components/admin/newsletters/AnalyticsTiles.vue +55 -0
  43. package/src/components/admin/newsletters/DeliveriesTable.stories.js +28 -0
  44. package/src/components/admin/newsletters/DeliveriesTable.vue +104 -0
  45. package/src/components/admin/newsletters/DynamicFilterBuilder.stories.js +11 -0
  46. package/src/components/admin/newsletters/DynamicFilterBuilder.vue +53 -0
  47. package/src/components/admin/newsletters/ListMemberTable.stories.js +12 -0
  48. package/src/components/admin/newsletters/ListMemberTable.vue +53 -0
  49. package/src/components/admin/newsletters/MarkdownEditor.stories.js +6 -0
  50. package/src/components/admin/newsletters/MarkdownEditor.vue +53 -0
  51. package/src/components/admin/newsletters/MergeFieldDropdown.stories.js +6 -0
  52. package/src/components/admin/newsletters/MergeFieldDropdown.vue +84 -0
  53. package/src/components/admin/newsletters/PreviewIframe.stories.js +7 -0
  54. package/src/components/admin/newsletters/PreviewIframe.vue +40 -0
  55. package/src/components/admin/newsletters/SummaryTiles.stories.js +8 -0
  56. package/src/components/admin/newsletters/SummaryTiles.vue +63 -0
  57. package/src/composables/useChat.js +187 -187
  58. package/src/composables/useI18n.js +30 -8
  59. package/src/composables/useMentions.js +134 -134
  60. package/src/composables/usePermissions.js +31 -0
  61. package/src/composables/useRealtime.js +182 -182
  62. package/src/index.js +85 -84
  63. package/src/locales/README.md +13 -0
  64. package/src/locales/ar.json +727 -727
  65. package/src/locales/de.json +690 -690
  66. package/src/locales/en.json +889 -729
  67. package/src/locales/es.json +691 -691
  68. package/src/locales/fr.json +691 -691
  69. package/src/locales/index.js +16 -16
  70. package/src/locales/it.json +727 -727
  71. package/src/locales/ja.json +727 -727
  72. package/src/locales/ko.json +727 -727
  73. package/src/locales/nl.json +727 -727
  74. package/src/locales/pl.json +727 -727
  75. package/src/locales/pt-BR.json +727 -727
  76. package/src/locales/ru.json +727 -727
  77. package/src/locales/tr.json +727 -727
  78. package/src/locales/zh-CN.json +727 -727
  79. package/src/pages/Admin/Newsletters/Compose.vue +192 -0
  80. package/src/pages/Admin/Newsletters/Edit.vue +24 -0
  81. package/src/pages/Admin/Newsletters/Index.vue +141 -0
  82. package/src/pages/Admin/Newsletters/Lists/Create.vue +64 -0
  83. package/src/pages/Admin/Newsletters/Lists/Index.vue +59 -0
  84. package/src/pages/Admin/Newsletters/Lists/Show.vue +53 -0
  85. package/src/pages/Admin/Newsletters/Settings.vue +85 -0
  86. package/src/pages/Admin/Newsletters/Show.vue +140 -0
  87. package/src/pages/Admin/Newsletters/Templates/Create.vue +11 -0
  88. package/src/pages/Admin/Newsletters/Templates/Index.vue +54 -0
  89. package/src/pages/Admin/Newsletters/Templates/Show.vue +65 -0
  90. package/src/pages/Admin/Newsletters/usePreview.js +44 -0
  91. package/src/pages/Admin/Reports/AgentDetail.vue +132 -132
  92. package/src/pages/Admin/Reports/AgentRanking.vue +246 -246
  93. package/src/pages/Admin/Reports/Cohorts.vue +180 -180
  94. package/src/pages/Admin/Reports/Comparison.vue +141 -141
  95. package/src/pages/Admin/Reports/Dashboard.vue +226 -226
  96. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -164
  97. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -164
  98. package/src/pages/Admin/Reports/SlaTrends.vue +183 -183
  99. package/src/pages/Admin/Settings/SsoSettings.vue +157 -4
  100. package/src/pages/Admin/Settings/TwoFactor.vue +32 -6
  101. package/src/pages/Admin/Settings.vue +1008 -1008
  102. package/src/pages/Admin/Skills/Form.vue +164 -4
  103. package/src/pages/Admin/Skills/Index.vue +14 -3
  104. package/src/pages/Admin/Tickets/Index.vue +126 -126
  105. package/src/pages/Admin/Tickets/Show.vue +254 -249
  106. package/src/pages/Admin/Users/Index.vue +145 -0
  107. package/src/pages/Admin/Workflows/Builder.vue +207 -207
  108. package/src/pages/Admin/Workflows/Index.vue +235 -235
  109. package/src/pages/Admin/Workflows/Logs.vue +286 -286
  110. package/src/pages/Agent/TicketIndex.vue +112 -112
  111. package/src/pages/Agent/TicketShow.vue +547 -494
  112. package/src/pages/Customer/Index.vue +26 -1
  113. package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
  114. package/src/plugin.js +175 -175
  115. package/src/utils/mentionHighlight.js +24 -24
  116. package/src/widget/EscalatedWidget.vue +804 -804
  117. package/src/widget/index.js +254 -254
@@ -1,494 +1,547 @@
1
- <script setup>
2
- import EscalatedLayout from '../../components/EscalatedLayout.vue';
3
- import StatusBadge from '../../components/StatusBadge.vue';
4
- import PriorityBadge from '../../components/PriorityBadge.vue';
5
- import ReplyThread from '../../components/ReplyThread.vue';
6
- import ReplyComposer from '../../components/ReplyComposer.vue';
7
- import ChatThread from '../../components/ChatThread.vue';
8
- import ChatComposer from '../../components/ChatComposer.vue';
9
- import ChatActionBar from '../../components/ChatActionBar.vue';
10
- import TicketSidebar from '../../components/TicketSidebar.vue';
11
- import AttachmentList from '../../components/AttachmentList.vue';
12
- import MacroDropdown from '../../components/MacroDropdown.vue';
13
- import FollowButton from '../../components/FollowButton.vue';
14
- import PresenceIndicator from '../../components/PresenceIndicator.vue';
15
- import PinnedNotes from '../../components/PinnedNotes.vue';
16
- import KeyboardShortcutHelp from '../../components/KeyboardShortcutHelp.vue';
17
- import PluginSlot from '../../components/PluginSlot.vue';
18
- import KnowledgePanel from '../../components/KnowledgePanel.vue';
19
- import ContextPanel from '../../components/ContextPanel.vue';
20
- import ContextPanelSection from '../../components/ContextPanelSection.vue';
21
- import { useKeyboardShortcuts } from '../../composables/useKeyboardShortcuts';
22
- import { usePluginExtensions } from '../../composables/usePluginExtensions';
23
- import { useRealtime } from '../../composables/useRealtime';
24
- import { useChat } from '../../composables/useChat';
25
- import { router, useForm, usePage } from '@inertiajs/vue3';
26
- import { ref, computed, inject, onMounted } from 'vue';
27
-
28
- const props = defineProps({
29
- ticket: Object,
30
- departments: Array,
31
- tags: Array,
32
- cannedResponses: Array,
33
- macros: { type: Array, default: () => [] },
34
- is_following: { type: Boolean, default: false },
35
- followers_count: { type: Number, default: 0 },
36
- });
37
-
38
- const page = usePage();
39
- const isLightAgent = computed(() => page.props.escalated?.agent_type === 'light');
40
- // Route prefix the host framework mounts Escalated under. Defaults to
41
- // `support`, matching every host-adapter plugin. NestJS uses `escalated`.
42
- const routePrefix = computed(() => page.props.escalated?.prefix || 'support');
43
- const widgetPath = computed(() => `/${routePrefix.value}/widget`);
44
- const activeTab = ref(isLightAgent.value ? 'note' : 'reply');
45
- const showShortcutHelp = ref(false);
46
- const replyComposerRef = ref(null);
47
- const statusSelectRef = ref(null);
48
- const prioritySelectRef = ref(null);
49
-
50
- const showContextPanel = ref(false);
51
-
52
- const statusForm = useForm({ status: '' });
53
- const priorityForm = useForm({ priority: '' });
54
- const assignForm = useForm({ agent_id: '' });
55
-
56
- function changeStatus(status) {
57
- statusForm.status = status;
58
- statusForm.post(route('escalated.agent.tickets.status', props.ticket.reference), { preserveScroll: true });
59
- }
60
-
61
- function changePriority(priority) {
62
- priorityForm.priority = priority;
63
- priorityForm.post(route('escalated.agent.tickets.priority', props.ticket.reference), { preserveScroll: true });
64
- }
65
-
66
- function assignToMe() {
67
- assignForm.agent_id = page.props.auth.user.id;
68
- assignForm.post(route('escalated.agent.tickets.assign', props.ticket.reference), { preserveScroll: true });
69
- }
70
-
71
- function toggleFollow() {
72
- router.post(route('escalated.agent.tickets.follow', props.ticket.reference), {}, { preserveScroll: true });
73
- }
74
-
75
- const escDark = inject(
76
- 'esc-dark',
77
- computed(() => false),
78
- );
79
-
80
- // Live chat mode detection
81
- const isChatMode = computed(() => props.ticket.channel === 'chat' && props.ticket.status === 'live');
82
- const chatMessages = ref(props.ticket.chat_messages || []);
83
- const chatTypingUser = ref(null);
84
-
85
- // Subscribe to chat channel for real-time messages. Pass widgetPath so
86
- // useChat's API calls resolve against whatever prefix the host framework
87
- // serves Escalated under (config on NestJS, default /support elsewhere).
88
- const { subscribeToChat } = useChat({ widgetPath: widgetPath.value });
89
- let chatTypingTimer = null;
90
-
91
- onMounted(() => {
92
- if (!isChatMode.value || !props.ticket.chat_session_id) return;
93
- subscribeToChat(props.ticket.chat_session_id, {
94
- onMessage(data) {
95
- chatMessages.value.push(data.message);
96
- },
97
- onTyping(data) {
98
- chatTypingUser.value = data.name;
99
- clearTimeout(chatTypingTimer);
100
- chatTypingTimer = setTimeout(() => {
101
- chatTypingUser.value = null;
102
- }, 4000);
103
- },
104
- onEnded() {
105
- router.reload({ preserveScroll: true });
106
- },
107
- onAssigned() {
108
- router.reload({ preserveScroll: true });
109
- },
110
- });
111
- });
112
-
113
- function onChatSend(payload) {
114
- // Optimistically add message to thread
115
- chatMessages.value.push({
116
- id: Date.now(),
117
- body: payload.body,
118
- is_internal_note: payload.is_internal_note,
119
- is_agent: true,
120
- author: { id: page.props.auth?.user?.id, name: page.props.auth?.user?.name },
121
- created_at: new Date().toISOString(),
122
- });
123
- }
124
-
125
- function onEndChat() {
126
- router.reload({ preserveScroll: true });
127
- }
128
-
129
- const { getPageComponents } = usePluginExtensions();
130
-
131
- // Keyboard shortcuts
132
- useKeyboardShortcuts({
133
- r: () => {
134
- activeTab.value = 'reply';
135
- replyComposerRef.value?.$el?.querySelector('textarea')?.focus();
136
- },
137
- n: () => {
138
- activeTab.value = 'note';
139
- },
140
- s: () => {
141
- statusSelectRef.value?.focus();
142
- },
143
- p: () => {
144
- prioritySelectRef.value?.focus();
145
- },
146
- f: () => {
147
- toggleFollow();
148
- },
149
- '?': () => {
150
- showShortcutHelp.value = true;
151
- },
152
- });
153
-
154
- // Real-time updates via WebSocket (gracefully degrades to no-op when Echo is absent)
155
- const { echoAvailable, subscribeToTicket } = useRealtime();
156
-
157
- onMounted(() => {
158
- if (!echoAvailable.value || !props.ticket?.id) return;
159
-
160
- subscribeToTicket(props.ticket.id, {
161
- onReplyCreated() {
162
- router.reload({ only: ['ticket'], preserveScroll: true });
163
- },
164
- onStatusChanged() {
165
- router.reload({ only: ['ticket'], preserveScroll: true });
166
- },
167
- onTicketUpdated() {
168
- router.reload({ only: ['ticket'], preserveScroll: true });
169
- },
170
- onTicketAssigned() {
171
- router.reload({ only: ['ticket'], preserveScroll: true });
172
- },
173
- onTicketEscalated() {
174
- router.reload({ only: ['ticket'], preserveScroll: true });
175
- },
176
- });
177
- });
178
- </script>
179
-
180
- <template>
181
- <EscalatedLayout :title="ticket.subject">
182
- <!-- Plugin: ticket show header slot -->
183
- <PluginSlot slot="ticket.show.header" :components="getPageComponents('ticket.show', 'header')" />
184
-
185
- <div class="mb-5 flex flex-wrap items-center gap-3">
186
- <span :class="['text-sm font-mono font-medium', escDark ? 'text-white' : 'text-gray-900']">{{
187
- ticket.reference
188
- }}</span>
189
- <StatusBadge :status="ticket.status" />
190
- <PriorityBadge :priority="ticket.priority" />
191
- <span class="text-sm text-neutral-500">by {{ ticket.requester?.name }}</span>
192
- <PresenceIndicator
193
- :ticket-reference="ticket.reference"
194
- :ticket-id="ticket.id"
195
- route-prefix="escalated.agent"
196
- />
197
- <div class="ml-auto flex items-center gap-2">
198
- <FollowButton
199
- :is-following="is_following"
200
- :followers-count="followers_count"
201
- :action="route('escalated.agent.tickets.follow', ticket.reference)"
202
- />
203
- <MacroDropdown
204
- v-if="macros.length && !isLightAgent"
205
- :macros="macros"
206
- :action="route('escalated.agent.tickets.macro', ticket.reference)"
207
- />
208
- <button
209
- v-if="!ticket.assigned_to && !isLightAgent"
210
- class="rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400"
211
- @click="assignToMe"
212
- >
213
- Assign to Me
214
- </button>
215
- <template v-if="!isLightAgent">
216
- <select
217
- ref="statusSelectRef"
218
- :class="[
219
- 'rounded-lg border px-3 py-1.5 text-sm focus:outline-none focus:ring-1',
220
- escDark
221
- ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-white/10'
222
- : 'border-gray-300 bg-white text-gray-700 focus:border-gray-400 focus:ring-gray-200',
223
- ]"
224
- @change="
225
- changeStatus($event.target.value);
226
- $event.target.value = '';
227
- "
228
- >
229
- <option value="">Change Status...</option>
230
- <option value="in_progress">In Progress</option>
231
- <option value="waiting_on_customer">Waiting on Customer</option>
232
- <option value="resolved">Resolved</option>
233
- <option value="closed">Closed</option>
234
- </select>
235
- <select
236
- ref="prioritySelectRef"
237
- :class="[
238
- 'rounded-lg border px-3 py-1.5 text-sm focus:outline-none focus:ring-1',
239
- escDark
240
- ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-white/10'
241
- : 'border-gray-300 bg-white text-gray-700 focus:border-gray-400 focus:ring-gray-200',
242
- ]"
243
- @change="
244
- changePriority($event.target.value);
245
- $event.target.value = '';
246
- "
247
- >
248
- <option value="">Change Priority...</option>
249
- <option value="low">Low</option>
250
- <option value="medium">Medium</option>
251
- <option value="high">High</option>
252
- <option value="urgent">Urgent</option>
253
- <option value="critical">Critical</option>
254
- </select>
255
- </template>
256
- <!-- Context Panel Toggle -->
257
- <button
258
- type="button"
259
- :class="[
260
- 'rounded-lg border px-3 py-1.5 text-sm transition-colors',
261
- showContextPanel
262
- ? escDark
263
- ? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-400'
264
- : 'border-blue-300 bg-blue-50 text-blue-600'
265
- : escDark
266
- ? 'border-white/10 text-neutral-400 hover:border-white/20 hover:text-neutral-200'
267
- : 'border-gray-300 text-gray-400 hover:border-gray-400 hover:text-gray-600',
268
- ]"
269
- @click="showContextPanel = !showContextPanel"
270
- >
271
- <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
272
- <path
273
- stroke-linecap="round"
274
- stroke-linejoin="round"
275
- d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
276
- />
277
- </svg>
278
- </button>
279
- <!-- Plugin: ticket show actions slot -->
280
- <PluginSlot slot="ticket.show.actions" :components="getPageComponents('ticket.show', 'actions')" />
281
- </div>
282
- </div>
283
- <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
284
- <div class="lg:col-span-2 space-y-6">
285
- <!-- Chat Action Bar (live chat mode) -->
286
- <ChatActionBar
287
- v-if="isChatMode"
288
- :session-id="ticket.chat_session_id"
289
- :started-at="ticket.chat_started_at || ticket.created_at"
290
- :customer="{
291
- name: ticket.requester?.name,
292
- email: ticket.requester?.email,
293
- page_url: ticket.chat_metadata?.page_url,
294
- }"
295
- :agents="[]"
296
- :departments="departments"
297
- @end-chat="onEndChat"
298
- />
299
-
300
- <PinnedNotes
301
- v-if="ticket.pinned_notes?.length"
302
- :notes="ticket.pinned_notes"
303
- :ticket-reference="ticket.reference"
304
- route-prefix="escalated.agent"
305
- />
306
- <div
307
- :class="[
308
- 'rounded-xl border p-5',
309
- escDark ? 'border-white/[0.06] bg-neutral-900/60' : 'border-gray-200 bg-white',
310
- ]"
311
- >
312
- <p :class="['whitespace-pre-wrap text-sm', escDark ? 'text-neutral-300' : 'text-gray-700']">
313
- {{ ticket.description }}
314
- </p>
315
- <AttachmentList v-if="ticket.attachments?.length" :attachments="ticket.attachments" class="mt-3" />
316
- </div>
317
- <!-- Live Chat Mode -->
318
- <template v-if="isChatMode">
319
- <div
320
- :class="[
321
- 'overflow-hidden rounded-xl border',
322
- escDark ? 'border-white/[0.06] bg-neutral-900/60' : 'border-gray-200 bg-white',
323
- ]"
324
- >
325
- <ChatThread
326
- :messages="chatMessages"
327
- :typing-user="chatTypingUser"
328
- :current-user-id="page.props.auth?.user?.id"
329
- />
330
- <ChatComposer
331
- :session-id="ticket.chat_session_id"
332
- :send-endpoint="
333
- ticket.chat_session_id ? `${widgetPath}/chat/${ticket.chat_session_id}/messages` : ''
334
- "
335
- :typing-endpoint="
336
- ticket.chat_session_id ? `${widgetPath}/chat/${ticket.chat_session_id}/typing` : ''
337
- "
338
- allow-notes
339
- @send="onChatSend"
340
- />
341
- </div>
342
- </template>
343
-
344
- <!-- Standard Reply Mode -->
345
- <template v-else>
346
- <div>
347
- <div :class="['mb-4 flex gap-4 border-b', escDark ? 'border-white/[0.06]' : 'border-gray-200']">
348
- <button
349
- v-if="!isLightAgent"
350
- :class="[
351
- 'pb-2 text-sm font-medium transition-colors',
352
- activeTab === 'reply'
353
- ? escDark
354
- ? 'border-b-2 border-cyan-500 text-white'
355
- : 'border-b-2 border-blue-500 text-gray-900'
356
- : escDark
357
- ? 'text-neutral-500 hover:text-neutral-300'
358
- : 'text-gray-400 hover:text-gray-600',
359
- ]"
360
- @click="activeTab = 'reply'"
361
- >
362
- Reply
363
- </button>
364
- <button
365
- :class="[
366
- 'pb-2 text-sm font-medium transition-colors',
367
- activeTab === 'note'
368
- ? escDark
369
- ? 'border-b-2 border-amber-500 text-amber-400'
370
- : 'border-b-2 border-amber-500 text-amber-700'
371
- : escDark
372
- ? 'text-neutral-500 hover:text-neutral-300'
373
- : 'text-gray-400 hover:text-gray-600',
374
- ]"
375
- @click="activeTab = 'note'"
376
- >
377
- Internal Note
378
- </button>
379
- </div>
380
- <ReplyComposer
381
- v-if="activeTab === 'reply' && !isLightAgent"
382
- ref="replyComposerRef"
383
- :action="route('escalated.agent.tickets.reply', ticket.reference)"
384
- :canned-responses="cannedResponses"
385
- />
386
- <ReplyComposer
387
- v-if="activeTab === 'note'"
388
- :action="route('escalated.agent.tickets.note', ticket.reference)"
389
- placeholder="Write an internal note..."
390
- submit-label="Add Note"
391
- />
392
- </div>
393
- <div>
394
- <h2 :class="['mb-4 text-lg font-semibold', escDark ? 'text-neutral-200' : 'text-gray-900']">
395
- Conversation
396
- </h2>
397
- <ReplyThread
398
- :replies="ticket.replies || []"
399
- :current-user-id="page.props.auth?.user?.id"
400
- :ticket-reference="ticket.reference"
401
- route-prefix="escalated.agent"
402
- pinnable
403
- />
404
- </div>
405
- </template>
406
- </div>
407
- <div class="space-y-6">
408
- <TicketSidebar :ticket="ticket" :tags="tags" :departments="departments" />
409
- <KnowledgePanel :ticket-reference="ticket.reference" />
410
- <!-- Plugin: ticket show sidebar slot -->
411
- <PluginSlot slot="ticket.show.sidebar" :components="getPageComponents('ticket.show', 'sidebar')" />
412
- </div>
413
- </div>
414
- <!-- Context Panel -->
415
- <ContextPanel v-model:visible="showContextPanel">
416
- <ContextPanelSection
417
- title="Customer Info"
418
- icon="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
419
- >
420
- <div class="space-y-2 text-sm">
421
- <div class="flex justify-between">
422
- <span :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Name</span>
423
- <span :class="escDark ? 'text-neutral-200' : 'text-gray-900'">{{
424
- ticket.requester?.name || 'Unknown'
425
- }}</span>
426
- </div>
427
- <div class="flex justify-between">
428
- <span :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Email</span>
429
- <span :class="['truncate', escDark ? 'text-neutral-200' : 'text-gray-900']">{{
430
- ticket.requester?.email || '---'
431
- }}</span>
432
- </div>
433
- <div class="flex justify-between">
434
- <span :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Tickets</span>
435
- <span :class="escDark ? 'text-neutral-200' : 'text-gray-900'">{{
436
- ticket.requester_ticket_count ?? '---'
437
- }}</span>
438
- </div>
439
- </div>
440
- </ContextPanelSection>
441
-
442
- <ContextPanelSection
443
- title="Related Tickets"
444
- icon="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
445
- :default-open="false"
446
- >
447
- <div v-if="ticket.related_tickets?.length" class="space-y-2">
448
- <div
449
- v-for="rt in ticket.related_tickets"
450
- :key="rt.reference"
451
- :class="[
452
- 'rounded-lg border px-3 py-2',
453
- escDark ? 'border-white/[0.06] bg-neutral-950' : 'border-gray-200 bg-gray-50',
454
- ]"
455
- >
456
- <span :class="['font-mono text-xs', escDark ? 'text-cyan-400' : 'text-blue-600']">{{
457
- rt.reference
458
- }}</span>
459
- <p :class="['mt-0.5 truncate text-xs', escDark ? 'text-neutral-400' : 'text-gray-500']">
460
- {{ rt.subject }}
461
- </p>
462
- </div>
463
- </div>
464
- <p v-else :class="['text-xs', escDark ? 'text-neutral-600' : 'text-gray-400']">
465
- No related tickets found.
466
- </p>
467
- </ContextPanelSection>
468
-
469
- <ContextPanelSection
470
- title="Recent Activity"
471
- icon="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
472
- :default-open="false"
473
- >
474
- <div v-if="ticket.activities?.length" class="space-y-2">
475
- <div v-for="activity in ticket.activities.slice(0, 5)" :key="activity.id" class="text-xs">
476
- <span :class="escDark ? 'text-neutral-500' : 'text-gray-600'">{{ activity.description }}</span>
477
- <span :class="['ml-1', escDark ? 'text-neutral-600' : 'text-gray-400']">{{
478
- activity.created_at_human
479
- }}</span>
480
- </div>
481
- </div>
482
- <p v-else :class="['text-xs', escDark ? 'text-neutral-600' : 'text-gray-400']">No recent activity.</p>
483
- </ContextPanelSection>
484
-
485
- <!-- Plugin: context panel slot -->
486
- <PluginSlot
487
- slot="ticket.show.context-panel"
488
- :components="getPageComponents('ticket.show', 'context-panel')"
489
- />
490
- </ContextPanel>
491
-
492
- <KeyboardShortcutHelp v-model:show="showShortcutHelp" context="detail" />
493
- </EscalatedLayout>
494
- </template>
1
+ <script setup>
2
+ import EscalatedLayout from '../../components/EscalatedLayout.vue';
3
+ import StatusBadge from '../../components/StatusBadge.vue';
4
+ import PriorityBadge from '../../components/PriorityBadge.vue';
5
+ import ReplyThread from '../../components/ReplyThread.vue';
6
+ import ReplyComposer from '../../components/ReplyComposer.vue';
7
+ import ChatThread from '../../components/ChatThread.vue';
8
+ import ChatComposer from '../../components/ChatComposer.vue';
9
+ import ChatActionBar from '../../components/ChatActionBar.vue';
10
+ import TicketSidebar from '../../components/TicketSidebar.vue';
11
+ import AttachmentList from '../../components/AttachmentList.vue';
12
+ import MacroDropdown from '../../components/MacroDropdown.vue';
13
+ import CustomTicketActions from '../../components/CustomTicketActions.vue';
14
+ import FollowButton from '../../components/FollowButton.vue';
15
+ import PresenceIndicator from '../../components/PresenceIndicator.vue';
16
+ import PinnedNotes from '../../components/PinnedNotes.vue';
17
+ import KeyboardShortcutHelp from '../../components/KeyboardShortcutHelp.vue';
18
+ import PluginSlot from '../../components/PluginSlot.vue';
19
+ import KnowledgePanel from '../../components/KnowledgePanel.vue';
20
+ import ContextPanel from '../../components/ContextPanel.vue';
21
+ import ContextPanelSection from '../../components/ContextPanelSection.vue';
22
+ import { useKeyboardShortcuts } from '../../composables/useKeyboardShortcuts';
23
+ import { usePluginExtensions } from '../../composables/usePluginExtensions';
24
+ import { useRealtime } from '../../composables/useRealtime';
25
+ import { useChat } from '../../composables/useChat';
26
+ import { router, useForm, usePage } from '@inertiajs/vue3';
27
+ import { ref, computed, inject, onUnmounted, watch } from 'vue';
28
+
29
+ const props = defineProps({
30
+ ticket: Object,
31
+ departments: Array,
32
+ tags: Array,
33
+ cannedResponses: Array,
34
+ macros: { type: Array, default: () => [] },
35
+ customActions: { type: Array, default: () => [] },
36
+ is_following: { type: Boolean, default: false },
37
+ followers_count: { type: Number, default: 0 },
38
+ });
39
+
40
+ const page = usePage();
41
+ const isLightAgent = computed(() => page.props.escalated?.agent_type === 'light');
42
+ // Route prefix the host framework mounts Escalated under. Defaults to
43
+ // `support`, matching every host-adapter plugin. NestJS uses `escalated`.
44
+ const routePrefix = computed(() => page.props.escalated?.prefix || 'support');
45
+ const widgetPath = computed(() => `/${routePrefix.value}/widget`);
46
+ const activeTab = ref(isLightAgent.value ? 'note' : 'reply');
47
+ const showShortcutHelp = ref(false);
48
+ const replyComposerRef = ref(null);
49
+ const statusSelectRef = ref(null);
50
+ const prioritySelectRef = ref(null);
51
+
52
+ const showContextPanel = ref(false);
53
+
54
+ const statusForm = useForm({ status: '' });
55
+ const priorityForm = useForm({ priority: '' });
56
+ const assignForm = useForm({ agent_id: '' });
57
+
58
+ function changeStatus(status) {
59
+ statusForm.status = status;
60
+ statusForm.post(route('escalated.agent.tickets.status', props.ticket.reference), { preserveScroll: true });
61
+ }
62
+
63
+ function changePriority(priority) {
64
+ priorityForm.priority = priority;
65
+ priorityForm.post(route('escalated.agent.tickets.priority', props.ticket.reference), { preserveScroll: true });
66
+ }
67
+
68
+ function assignToMe() {
69
+ // auth.user may be absent if the host app hasn't shared it (other call
70
+ // sites in this page already guard with auth?.user?.id). Bail rather than
71
+ // crash on the click.
72
+ const userId = page.props.auth?.user?.id;
73
+ if (userId == null) return;
74
+ assignForm.agent_id = userId;
75
+ assignForm.post(route('escalated.agent.tickets.assign', props.ticket.reference), { preserveScroll: true });
76
+ }
77
+
78
+ function toggleFollow() {
79
+ router.post(route('escalated.agent.tickets.follow', props.ticket.reference), {}, { preserveScroll: true });
80
+ }
81
+
82
+ const escDark = inject(
83
+ 'esc-dark',
84
+ computed(() => false),
85
+ );
86
+
87
+ // Live chat mode detection
88
+ const isChatMode = computed(() => props.ticket.channel === 'chat' && props.ticket.status === 'live');
89
+ const chatMessages = ref(props.ticket.chat_messages || []);
90
+ const chatTypingUser = ref(null);
91
+
92
+ // Subscribe to chat channel for real-time messages. Pass widgetPath so
93
+ // useChat's API calls resolve against whatever prefix the host framework
94
+ // serves Escalated under (config on NestJS, default /support elsewhere).
95
+ const { subscribeToChat } = useChat({ widgetPath: widgetPath.value });
96
+ let chatTypingTimer = null;
97
+ let chatUnsubscribe = null;
98
+
99
+ // Re-seed the chat thread whenever we navigate to a different ticket. Inertia
100
+ // reuses this page component across ticket navigation (setup() does not re-run),
101
+ // so without this the previous ticket's chat would persist.
102
+ watch(
103
+ () => props.ticket.id,
104
+ () => {
105
+ chatMessages.value = props.ticket.chat_messages || [];
106
+ },
107
+ );
108
+
109
+ // (Re)subscribe to the chat channel whenever the live chat session changes —
110
+ // including navigating from a non-chat ticket to a chat ticket. The old
111
+ // subscription is torn down first so we never leak a channel or receive
112
+ // messages for the ticket we just left.
113
+ function setupChatSubscription() {
114
+ if (chatUnsubscribe) {
115
+ chatUnsubscribe();
116
+ chatUnsubscribe = null;
117
+ }
118
+ if (!isChatMode.value || !props.ticket.chat_session_id) return;
119
+ chatUnsubscribe = subscribeToChat(props.ticket.chat_session_id, {
120
+ onMessage(data) {
121
+ chatMessages.value.push(data.message);
122
+ },
123
+ onTyping(data) {
124
+ chatTypingUser.value = data.name;
125
+ clearTimeout(chatTypingTimer);
126
+ chatTypingTimer = setTimeout(() => {
127
+ chatTypingUser.value = null;
128
+ }, 4000);
129
+ },
130
+ onEnded() {
131
+ router.reload({ preserveScroll: true });
132
+ },
133
+ onAssigned() {
134
+ router.reload({ preserveScroll: true });
135
+ },
136
+ });
137
+ }
138
+
139
+ watch(() => [props.ticket.chat_session_id, isChatMode.value], setupChatSubscription, {
140
+ immediate: true,
141
+ });
142
+
143
+ onUnmounted(() => {
144
+ if (chatUnsubscribe) chatUnsubscribe();
145
+ clearTimeout(chatTypingTimer);
146
+ });
147
+
148
+ function onChatSend(payload) {
149
+ // Optimistically add message to thread
150
+ chatMessages.value.push({
151
+ id: Date.now(),
152
+ body: payload.body,
153
+ is_internal_note: payload.is_internal_note,
154
+ is_agent: true,
155
+ author: { id: page.props.auth?.user?.id, name: page.props.auth?.user?.name },
156
+ created_at: new Date().toISOString(),
157
+ });
158
+ }
159
+
160
+ function onEndChat() {
161
+ router.reload({ preserveScroll: true });
162
+ }
163
+
164
+ const { getPageComponents } = usePluginExtensions();
165
+
166
+ // Keyboard shortcuts
167
+ useKeyboardShortcuts({
168
+ r: () => {
169
+ activeTab.value = 'reply';
170
+ replyComposerRef.value?.$el?.querySelector('textarea')?.focus();
171
+ },
172
+ n: () => {
173
+ activeTab.value = 'note';
174
+ },
175
+ s: () => {
176
+ statusSelectRef.value?.focus();
177
+ },
178
+ p: () => {
179
+ prioritySelectRef.value?.focus();
180
+ },
181
+ f: () => {
182
+ toggleFollow();
183
+ },
184
+ '?': () => {
185
+ showShortcutHelp.value = true;
186
+ },
187
+ });
188
+
189
+ // Real-time updates via WebSocket (gracefully degrades to no-op when Echo is absent)
190
+ const { echoAvailable, subscribeToTicket } = useRealtime();
191
+ let ticketUnsubscribe = null;
192
+
193
+ // (Re)subscribe whenever the ticket id changes. Because Inertia reuses this
194
+ // page across ticket navigation, an onMounted-only subscription would stay
195
+ // bound to the first ticket — realtime updates for the ticket you navigated to
196
+ // would never arrive, and the previous ticket's events would keep triggering
197
+ // reloads. Tear down the old subscription before binding the new one.
198
+ function setupTicketSubscription() {
199
+ if (ticketUnsubscribe) {
200
+ ticketUnsubscribe();
201
+ ticketUnsubscribe = null;
202
+ }
203
+ if (!echoAvailable.value || !props.ticket?.id) return;
204
+
205
+ ticketUnsubscribe = subscribeToTicket(props.ticket.id, {
206
+ onReplyCreated() {
207
+ router.reload({ only: ['ticket'], preserveScroll: true });
208
+ },
209
+ onStatusChanged() {
210
+ router.reload({ only: ['ticket'], preserveScroll: true });
211
+ },
212
+ onTicketUpdated() {
213
+ router.reload({ only: ['ticket'], preserveScroll: true });
214
+ },
215
+ onTicketAssigned() {
216
+ router.reload({ only: ['ticket'], preserveScroll: true });
217
+ },
218
+ onTicketEscalated() {
219
+ router.reload({ only: ['ticket'], preserveScroll: true });
220
+ },
221
+ });
222
+ }
223
+
224
+ watch(() => props.ticket?.id, setupTicketSubscription, { immediate: true });
225
+
226
+ onUnmounted(() => {
227
+ if (ticketUnsubscribe) ticketUnsubscribe();
228
+ });
229
+ </script>
230
+
231
+ <template>
232
+ <EscalatedLayout :title="ticket.subject">
233
+ <!-- Plugin: ticket show header slot -->
234
+ <PluginSlot slot="ticket.show.header" :components="getPageComponents('ticket.show', 'header')" />
235
+
236
+ <div class="mb-5 flex flex-wrap items-center gap-3">
237
+ <span :class="['text-sm font-mono font-medium', escDark ? 'text-white' : 'text-gray-900']">{{
238
+ ticket.reference
239
+ }}</span>
240
+ <StatusBadge :status="ticket.status" />
241
+ <PriorityBadge :priority="ticket.priority" />
242
+ <span class="text-sm text-neutral-500">by {{ ticket.requester?.name }}</span>
243
+ <PresenceIndicator
244
+ :ticket-reference="ticket.reference"
245
+ :ticket-id="ticket.id"
246
+ route-prefix="escalated.agent"
247
+ />
248
+ <div class="ml-auto flex items-center gap-2">
249
+ <FollowButton
250
+ :is-following="is_following"
251
+ :followers-count="followers_count"
252
+ :action="route('escalated.agent.tickets.follow', ticket.reference)"
253
+ />
254
+ <MacroDropdown
255
+ v-if="macros.length && !isLightAgent"
256
+ :macros="macros"
257
+ :action="route('escalated.agent.tickets.macro', ticket.reference)"
258
+ />
259
+ <button
260
+ v-if="!ticket.assigned_to && !isLightAgent"
261
+ class="rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400"
262
+ @click="assignToMe"
263
+ >
264
+ Assign to Me
265
+ </button>
266
+ <template v-if="!isLightAgent">
267
+ <select
268
+ ref="statusSelectRef"
269
+ :class="[
270
+ 'rounded-lg border px-3 py-1.5 text-sm focus:outline-none focus:ring-1',
271
+ escDark
272
+ ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-white/10'
273
+ : 'border-gray-300 bg-white text-gray-700 focus:border-gray-400 focus:ring-gray-200',
274
+ ]"
275
+ @change="
276
+ changeStatus($event.target.value);
277
+ $event.target.value = '';
278
+ "
279
+ >
280
+ <option value="">Change Status...</option>
281
+ <option value="in_progress">In Progress</option>
282
+ <option value="waiting_on_customer">Waiting on Customer</option>
283
+ <option value="resolved">Resolved</option>
284
+ <option value="closed">Closed</option>
285
+ </select>
286
+ <select
287
+ ref="prioritySelectRef"
288
+ :class="[
289
+ 'rounded-lg border px-3 py-1.5 text-sm focus:outline-none focus:ring-1',
290
+ escDark
291
+ ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-white/10'
292
+ : 'border-gray-300 bg-white text-gray-700 focus:border-gray-400 focus:ring-gray-200',
293
+ ]"
294
+ @change="
295
+ changePriority($event.target.value);
296
+ $event.target.value = '';
297
+ "
298
+ >
299
+ <option value="">Change Priority...</option>
300
+ <option value="low">Low</option>
301
+ <option value="medium">Medium</option>
302
+ <option value="high">High</option>
303
+ <option value="urgent">Urgent</option>
304
+ <option value="critical">Critical</option>
305
+ </select>
306
+ </template>
307
+ <!-- Host-defined custom ticket actions -->
308
+ <CustomTicketActions :actions="customActions" />
309
+ <!-- Context Panel Toggle -->
310
+ <button
311
+ type="button"
312
+ :class="[
313
+ 'rounded-lg border px-3 py-1.5 text-sm transition-colors',
314
+ showContextPanel
315
+ ? escDark
316
+ ? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-400'
317
+ : 'border-blue-300 bg-blue-50 text-blue-600'
318
+ : escDark
319
+ ? 'border-white/10 text-neutral-400 hover:border-white/20 hover:text-neutral-200'
320
+ : 'border-gray-300 text-gray-400 hover:border-gray-400 hover:text-gray-600',
321
+ ]"
322
+ @click="showContextPanel = !showContextPanel"
323
+ >
324
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
325
+ <path
326
+ stroke-linecap="round"
327
+ stroke-linejoin="round"
328
+ d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
329
+ />
330
+ </svg>
331
+ </button>
332
+ <!-- Plugin: ticket show actions slot -->
333
+ <PluginSlot slot="ticket.show.actions" :components="getPageComponents('ticket.show', 'actions')" />
334
+ </div>
335
+ </div>
336
+ <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
337
+ <div class="lg:col-span-2 space-y-6">
338
+ <!-- Chat Action Bar (live chat mode) -->
339
+ <ChatActionBar
340
+ v-if="isChatMode"
341
+ :session-id="ticket.chat_session_id"
342
+ :started-at="ticket.chat_started_at || ticket.created_at"
343
+ :customer="{
344
+ name: ticket.requester?.name,
345
+ email: ticket.requester?.email,
346
+ page_url: ticket.chat_metadata?.page_url,
347
+ }"
348
+ :agents="[]"
349
+ :departments="departments"
350
+ @end-chat="onEndChat"
351
+ />
352
+
353
+ <PinnedNotes
354
+ v-if="ticket.pinned_notes?.length"
355
+ :notes="ticket.pinned_notes"
356
+ :ticket-reference="ticket.reference"
357
+ route-prefix="escalated.agent"
358
+ />
359
+ <div
360
+ :class="[
361
+ 'rounded-xl border p-5',
362
+ escDark ? 'border-white/[0.06] bg-neutral-900/60' : 'border-gray-200 bg-white',
363
+ ]"
364
+ >
365
+ <p :class="['whitespace-pre-wrap text-sm', escDark ? 'text-neutral-300' : 'text-gray-700']">
366
+ {{ ticket.description }}
367
+ </p>
368
+ <AttachmentList v-if="ticket.attachments?.length" :attachments="ticket.attachments" class="mt-3" />
369
+ </div>
370
+ <!-- Live Chat Mode -->
371
+ <template v-if="isChatMode">
372
+ <div
373
+ :class="[
374
+ 'overflow-hidden rounded-xl border',
375
+ escDark ? 'border-white/[0.06] bg-neutral-900/60' : 'border-gray-200 bg-white',
376
+ ]"
377
+ >
378
+ <ChatThread
379
+ :messages="chatMessages"
380
+ :typing-user="chatTypingUser"
381
+ :current-user-id="page.props.auth?.user?.id"
382
+ />
383
+ <ChatComposer
384
+ :session-id="ticket.chat_session_id"
385
+ :send-endpoint="
386
+ ticket.chat_session_id ? `${widgetPath}/chat/${ticket.chat_session_id}/messages` : ''
387
+ "
388
+ :typing-endpoint="
389
+ ticket.chat_session_id ? `${widgetPath}/chat/${ticket.chat_session_id}/typing` : ''
390
+ "
391
+ allow-notes
392
+ @send="onChatSend"
393
+ />
394
+ </div>
395
+ </template>
396
+
397
+ <!-- Standard Reply Mode -->
398
+ <template v-else>
399
+ <div>
400
+ <div :class="['mb-4 flex gap-4 border-b', escDark ? 'border-white/[0.06]' : 'border-gray-200']">
401
+ <button
402
+ v-if="!isLightAgent"
403
+ :class="[
404
+ 'pb-2 text-sm font-medium transition-colors',
405
+ activeTab === 'reply'
406
+ ? escDark
407
+ ? 'border-b-2 border-cyan-500 text-white'
408
+ : 'border-b-2 border-blue-500 text-gray-900'
409
+ : escDark
410
+ ? 'text-neutral-500 hover:text-neutral-300'
411
+ : 'text-gray-400 hover:text-gray-600',
412
+ ]"
413
+ @click="activeTab = 'reply'"
414
+ >
415
+ Reply
416
+ </button>
417
+ <button
418
+ :class="[
419
+ 'pb-2 text-sm font-medium transition-colors',
420
+ activeTab === 'note'
421
+ ? escDark
422
+ ? 'border-b-2 border-amber-500 text-amber-400'
423
+ : 'border-b-2 border-amber-500 text-amber-700'
424
+ : escDark
425
+ ? 'text-neutral-500 hover:text-neutral-300'
426
+ : 'text-gray-400 hover:text-gray-600',
427
+ ]"
428
+ @click="activeTab = 'note'"
429
+ >
430
+ Internal Note
431
+ </button>
432
+ </div>
433
+ <ReplyComposer
434
+ v-if="activeTab === 'reply' && !isLightAgent"
435
+ ref="replyComposerRef"
436
+ :action="route('escalated.agent.tickets.reply', ticket.reference)"
437
+ :canned-responses="cannedResponses"
438
+ />
439
+ <ReplyComposer
440
+ v-if="activeTab === 'note'"
441
+ :action="route('escalated.agent.tickets.note', ticket.reference)"
442
+ placeholder="Write an internal note..."
443
+ submit-label="Add Note"
444
+ />
445
+ </div>
446
+ <div>
447
+ <h2 :class="['mb-4 text-lg font-semibold', escDark ? 'text-neutral-200' : 'text-gray-900']">
448
+ Conversation
449
+ </h2>
450
+ <ReplyThread
451
+ :replies="ticket.replies || []"
452
+ :current-user-id="page.props.auth?.user?.id"
453
+ :ticket-reference="ticket.reference"
454
+ route-prefix="escalated.agent"
455
+ pinnable
456
+ />
457
+ </div>
458
+ </template>
459
+ </div>
460
+ <div class="space-y-6">
461
+ <TicketSidebar :ticket="ticket" :tags="tags" :departments="departments" />
462
+ <KnowledgePanel :ticket-reference="ticket.reference" />
463
+ <!-- Plugin: ticket show sidebar slot -->
464
+ <PluginSlot slot="ticket.show.sidebar" :components="getPageComponents('ticket.show', 'sidebar')" />
465
+ </div>
466
+ </div>
467
+ <!-- Context Panel -->
468
+ <ContextPanel v-model:visible="showContextPanel">
469
+ <ContextPanelSection
470
+ title="Customer Info"
471
+ icon="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
472
+ >
473
+ <div class="space-y-2 text-sm">
474
+ <div class="flex justify-between">
475
+ <span :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Name</span>
476
+ <span :class="escDark ? 'text-neutral-200' : 'text-gray-900'">{{
477
+ ticket.requester?.name || 'Unknown'
478
+ }}</span>
479
+ </div>
480
+ <div class="flex justify-between">
481
+ <span :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Email</span>
482
+ <span :class="['truncate', escDark ? 'text-neutral-200' : 'text-gray-900']">{{
483
+ ticket.requester?.email || '---'
484
+ }}</span>
485
+ </div>
486
+ <div class="flex justify-between">
487
+ <span :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Tickets</span>
488
+ <span :class="escDark ? 'text-neutral-200' : 'text-gray-900'">{{
489
+ ticket.requester_ticket_count ?? '---'
490
+ }}</span>
491
+ </div>
492
+ </div>
493
+ </ContextPanelSection>
494
+
495
+ <ContextPanelSection
496
+ title="Related Tickets"
497
+ icon="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
498
+ :default-open="false"
499
+ >
500
+ <div v-if="ticket.related_tickets?.length" class="space-y-2">
501
+ <div
502
+ v-for="rt in ticket.related_tickets"
503
+ :key="rt.reference"
504
+ :class="[
505
+ 'rounded-lg border px-3 py-2',
506
+ escDark ? 'border-white/[0.06] bg-neutral-950' : 'border-gray-200 bg-gray-50',
507
+ ]"
508
+ >
509
+ <span :class="['font-mono text-xs', escDark ? 'text-cyan-400' : 'text-blue-600']">{{
510
+ rt.reference
511
+ }}</span>
512
+ <p :class="['mt-0.5 truncate text-xs', escDark ? 'text-neutral-400' : 'text-gray-500']">
513
+ {{ rt.subject }}
514
+ </p>
515
+ </div>
516
+ </div>
517
+ <p v-else :class="['text-xs', escDark ? 'text-neutral-600' : 'text-gray-400']">
518
+ No related tickets found.
519
+ </p>
520
+ </ContextPanelSection>
521
+
522
+ <ContextPanelSection
523
+ title="Recent Activity"
524
+ icon="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
525
+ :default-open="false"
526
+ >
527
+ <div v-if="ticket.activities?.length" class="space-y-2">
528
+ <div v-for="activity in ticket.activities.slice(0, 5)" :key="activity.id" class="text-xs">
529
+ <span :class="escDark ? 'text-neutral-500' : 'text-gray-600'">{{ activity.description }}</span>
530
+ <span :class="['ml-1', escDark ? 'text-neutral-600' : 'text-gray-400']">{{
531
+ activity.created_at_human
532
+ }}</span>
533
+ </div>
534
+ </div>
535
+ <p v-else :class="['text-xs', escDark ? 'text-neutral-600' : 'text-gray-400']">No recent activity.</p>
536
+ </ContextPanelSection>
537
+
538
+ <!-- Plugin: context panel slot -->
539
+ <PluginSlot
540
+ slot="ticket.show.context-panel"
541
+ :components="getPageComponents('ticket.show', 'context-panel')"
542
+ />
543
+ </ContextPanel>
544
+
545
+ <KeyboardShortcutHelp v-model:show="showShortcutHelp" context="detail" />
546
+ </EscalatedLayout>
547
+ </template>