@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,655 +1,655 @@
1
- <script setup>
2
- /* global localStorage */
3
- import { inject, computed, ref, watch } from 'vue';
4
- import StatusBadge from './StatusBadge.vue';
5
- import PriorityBadge from './PriorityBadge.vue';
6
- import { Link, router } from '@inertiajs/vue3';
7
- import { TICKET_TYPE_COLORS } from '../utils/constants';
8
- import { timeAgo, slaClass } from '../utils/formatting';
9
-
10
- const STORAGE_KEY = 'escalated-ticket-columns';
11
-
12
- const ALL_COLUMNS = [
13
- { key: 'reference', label: 'Reference' },
14
- { key: 'subject', label: 'Subject' },
15
- { key: 'requester', label: 'Requester' },
16
- { key: 'status', label: 'Status' },
17
- { key: 'priority', label: 'Priority' },
18
- { key: 'assignee', label: 'Assignee' },
19
- { key: 'department', label: 'Department' },
20
- { key: 'updated', label: 'Last Reply' },
21
- { key: 'created', label: 'Created' },
22
- { key: 'sla', label: 'SLA' },
23
- { key: 'tags', label: 'Tags' },
24
- { key: 'category', label: 'Category' },
25
- ];
26
-
27
- const props = defineProps({
28
- tickets: { type: Object, required: true },
29
- routePrefix: { type: String, default: 'escalated.customer.tickets' },
30
- /** @deprecated Use `columns` prop instead */
31
- showAssignee: { type: Boolean, default: false },
32
- columns: {
33
- type: Array,
34
- default: () => ['reference', 'subject', 'requester', 'status', 'priority', 'updated'],
35
- },
36
- selectable: { type: Boolean, default: false },
37
- selectedIds: { type: Array, default: () => [] },
38
- assignRoute: { type: String, default: '' },
39
- navigable: { type: Boolean, default: false },
40
- showLiveBadge: { type: Boolean, default: false },
41
- });
42
-
43
- const emit = defineEmits(['update:selectedIds']);
44
- const escDark = inject(
45
- 'esc-dark',
46
- computed(() => false),
47
- );
48
- const focusedIndex = ref(-1);
49
- const showColumnPicker = ref(false);
50
-
51
- // Resolve initial columns: merge prop with deprecated showAssignee, then override from localStorage
52
- function resolveDefaultColumns() {
53
- const base = [...props.columns];
54
- if (props.showAssignee && !base.includes('assignee')) {
55
- const prioIdx = base.indexOf('priority');
56
- if (prioIdx >= 0) {
57
- base.splice(prioIdx + 1, 0, 'assignee');
58
- } else {
59
- base.push('assignee');
60
- }
61
- }
62
- return base;
63
- }
64
-
65
- function loadSavedColumns() {
66
- try {
67
- const saved = localStorage.getItem(STORAGE_KEY);
68
- if (saved) {
69
- const parsed = JSON.parse(saved);
70
- if (Array.isArray(parsed) && parsed.length > 0) return parsed;
71
- }
72
- } catch {
73
- // ignore
74
- }
75
- return null;
76
- }
77
-
78
- const activeColumns = ref(loadSavedColumns() || resolveDefaultColumns());
79
-
80
- function saveColumns() {
81
- try {
82
- localStorage.setItem(STORAGE_KEY, JSON.stringify(activeColumns.value));
83
- } catch {
84
- // ignore
85
- }
86
- }
87
-
88
- function toggleColumn(key) {
89
- const idx = activeColumns.value.indexOf(key);
90
- if (idx >= 0) {
91
- if (activeColumns.value.length <= 1) return; // must keep at least one
92
- activeColumns.value.splice(idx, 1);
93
- } else {
94
- activeColumns.value.push(key);
95
- }
96
- saveColumns();
97
- }
98
-
99
- function isColumnActive(key) {
100
- return activeColumns.value.includes(key);
101
- }
102
-
103
- const visibleColumnDefs = computed(() => {
104
- return ALL_COLUMNS.filter((c) => activeColumns.value.includes(c.key));
105
- });
106
-
107
- // Close column picker on outside click
108
- function onClickOutside(e) {
109
- if (!e.target.closest('[data-column-picker]')) {
110
- showColumnPicker.value = false;
111
- }
112
- }
113
-
114
- watch(showColumnPicker, (val) => {
115
- if (val) {
116
- document.addEventListener('click', onClickOutside, true);
117
- } else {
118
- document.removeEventListener('click', onClickOutside, true);
119
- }
120
- });
121
-
122
- function moveDown() {
123
- if (!props.tickets.data?.length) return;
124
- focusedIndex.value = Math.min(focusedIndex.value + 1, props.tickets.data.length - 1);
125
- }
126
- function moveUp() {
127
- if (!props.tickets.data?.length) return;
128
- focusedIndex.value = Math.max(focusedIndex.value - 1, 0);
129
- }
130
- function toggleSelectFocused() {
131
- if (focusedIndex.value < 0 || !props.tickets.data?.length) return;
132
- toggleOne(props.tickets.data[focusedIndex.value].id);
133
- }
134
- function openFocused() {
135
- if (focusedIndex.value < 0 || !props.tickets.data?.length) return;
136
- const ticket = props.tickets.data[focusedIndex.value];
137
- router.visit(route(`${props.routePrefix}.show`, ticket.reference));
138
- }
139
-
140
- defineExpose({ moveDown, moveUp, toggleSelectFocused, openFocused });
141
-
142
- const allSelected = computed(() => {
143
- if (!props.tickets.data?.length) return false;
144
- return props.tickets.data.every((t) => props.selectedIds.includes(t.id));
145
- });
146
-
147
- function toggleAll() {
148
- if (allSelected.value) {
149
- emit('update:selectedIds', []);
150
- } else {
151
- emit(
152
- 'update:selectedIds',
153
- props.tickets.data.map((t) => t.id),
154
- );
155
- }
156
- }
157
-
158
- function toggleOne(id) {
159
- const ids = [...props.selectedIds];
160
- const idx = ids.indexOf(id);
161
- if (idx >= 0) ids.splice(idx, 1);
162
- else ids.push(id);
163
- emit('update:selectedIds', ids);
164
- }
165
-
166
- function slaLabel(ticket) {
167
- if (ticket.sla_first_response_breached || ticket.sla_resolution_breached) return 'Breached';
168
- if (ticket.first_response_due_at || ticket.resolution_due_at) {
169
- const due = ticket.resolution_due_at || ticket.first_response_due_at;
170
- const mins = Math.round((new Date(due) - Date.now()) / 60000);
171
- if (mins < 0) return 'Overdue';
172
- if (mins < 60) return `${mins}m left`;
173
- const hrs = Math.floor(mins / 60);
174
- if (hrs < 24) return `${hrs}h left`;
175
- return `${Math.floor(hrs / 24)}d left`;
176
- }
177
- return '—';
178
- }
179
-
180
- function ticketTypeLabel(type) {
181
- const entry = TICKET_TYPE_COLORS[type];
182
- if (!entry) return type || '—';
183
- // Derive label from key
184
- return type.charAt(0).toUpperCase() + type.slice(1);
185
- }
186
-
187
- function ticketTypeBadgeClass(type, dark) {
188
- const entry = TICKET_TYPE_COLORS[type];
189
- if (!entry) return '';
190
- return dark ? entry.dark : entry.light;
191
- }
192
-
193
- function truncate(str, len = 60) {
194
- if (!str) return '';
195
- return str.length > len ? str.slice(0, len) + '...' : str;
196
- }
197
-
198
- const colCount = computed(() => {
199
- let count = visibleColumnDefs.value.length;
200
- if (props.selectable) count++;
201
- return count;
202
- });
203
- </script>
204
-
205
- <template>
206
- <!-- Dark mode -->
207
- <div v-if="escDark" class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
208
- <table class="min-w-full divide-y divide-white/[0.06]">
209
- <thead>
210
- <tr class="bg-white/[0.02]">
211
- <th v-if="selectable" scope="col" class="w-10 px-3 py-3">
212
- <input
213
- type="checkbox"
214
- :checked="allSelected"
215
- aria-label="Select all tickets"
216
- class="h-4 w-4 rounded border-white/20 bg-neutral-950 text-cyan-500 focus:ring-cyan-500/20"
217
- @change="toggleAll"
218
- />
219
- </th>
220
- <th
221
- v-for="col in visibleColumnDefs"
222
- :key="col.key"
223
- scope="col"
224
- class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500"
225
- >
226
- {{ col.label }}
227
- </th>
228
- <th scope="col" class="w-10 px-2 py-3 text-right">
229
- <div class="relative inline-block" data-column-picker>
230
- <button
231
- aria-label="Configure columns"
232
- :class="[
233
- 'inline-flex items-center justify-center h-7 w-7 rounded-md transition-colors',
234
- 'text-neutral-500 hover:text-neutral-300 hover:bg-white/[0.06]',
235
- ]"
236
- @click.stop="showColumnPicker = !showColumnPicker"
237
- >
238
- <svg
239
- xmlns="http://www.w3.org/2000/svg"
240
- class="h-4 w-4"
241
- viewBox="0 0 20 20"
242
- fill="currentColor"
243
- >
244
- <path
245
- fill-rule="evenodd"
246
- d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
247
- clip-rule="evenodd"
248
- />
249
- </svg>
250
- </button>
251
- <div
252
- v-if="showColumnPicker"
253
- role="menu"
254
- aria-label="Column visibility"
255
- class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-white/[0.08] bg-neutral-800 py-1 shadow-xl"
256
- >
257
- <label
258
- v-for="col in ALL_COLUMNS"
259
- :key="col.key"
260
- role="menuitemcheckbox"
261
- :aria-checked="isColumnActive(col.key)"
262
- class="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm text-neutral-300 hover:bg-white/[0.06]"
263
- >
264
- <input
265
- type="checkbox"
266
- :checked="isColumnActive(col.key)"
267
- class="h-3.5 w-3.5 rounded border-white/20 bg-neutral-900 text-cyan-500 focus:ring-cyan-500/20"
268
- @change="toggleColumn(col.key)"
269
- />
270
- {{ col.label }}
271
- </label>
272
- </div>
273
- </div>
274
- </th>
275
- </tr>
276
- </thead>
277
- <tbody class="divide-y divide-white/[0.04]">
278
- <tr
279
- v-for="(ticket, index) in tickets.data"
280
- :key="ticket.id"
281
- :class="[
282
- 'group transition-colors',
283
- navigable && focusedIndex === index ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]',
284
- ]"
285
- >
286
- <td v-if="selectable" class="w-10 px-3 py-3">
287
- <input
288
- type="checkbox"
289
- :checked="selectedIds.includes(ticket.id)"
290
- aria-label="Select ticket"
291
- class="h-4 w-4 rounded border-white/20 bg-neutral-950 text-cyan-500 focus:ring-cyan-500/20"
292
- @change="toggleOne(ticket.id)"
293
- />
294
- </td>
295
- <!-- reference -->
296
- <td v-if="isColumnActive('reference')" class="whitespace-nowrap px-4 py-3 text-sm font-medium">
297
- <div class="flex items-center gap-2">
298
- <span
299
- v-if="slaClass(ticket)"
300
- :class="['h-2 w-2 shrink-0 rounded-full', slaClass(ticket)]"
301
- ></span>
302
- <Link
303
- :href="route(`${routePrefix}.show`, ticket.reference)"
304
- class="text-white hover:text-neutral-300"
305
- >
306
- {{ ticket.reference }}
307
- </Link>
308
- </div>
309
- </td>
310
- <!-- subject -->
311
- <td v-if="isColumnActive('subject')" class="px-4 py-3 text-sm text-neutral-300">
312
- {{ truncate(ticket.subject) }}
313
- </td>
314
- <!-- requester -->
315
- <td v-if="isColumnActive('requester')" class="px-4 py-3 text-sm text-neutral-400">
316
- <div>{{ ticket.requester?.name || ticket.requester_name || 'Unknown' }}</div>
317
- <div class="truncate text-xs text-neutral-600" style="max-width: 150px">
318
- {{ ticket.requester?.email || ticket.requester_email || '' }}
319
- </div>
320
- </td>
321
- <!-- status -->
322
- <td v-if="isColumnActive('status')" class="px-4 py-3">
323
- <div class="flex items-center gap-1.5">
324
- <StatusBadge :status="ticket.status" />
325
- <span
326
- v-if="showLiveBadge && ticket.channel === 'chat' && ticket.status === 'live'"
327
- class="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400 ring-1 ring-emerald-500/20"
328
- >
329
- <span class="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400"></span>
330
- Live
331
- </span>
332
- <span
333
- v-if="ticket.snoozed_until && new Date(ticket.snoozed_until) > Date.now()"
334
- class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 ring-1 ring-amber-500/20"
335
- :title="'Snoozed until ' + new Date(ticket.snoozed_until).toLocaleString()"
336
- >
337
- <svg
338
- xmlns="http://www.w3.org/2000/svg"
339
- class="h-3 w-3"
340
- viewBox="0 0 20 20"
341
- fill="currentColor"
342
- >
343
- <path
344
- fill-rule="evenodd"
345
- d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.828a1 1 0 101.415-1.414L11 9.586V6z"
346
- clip-rule="evenodd"
347
- />
348
- </svg>
349
- Snoozed
350
- </span>
351
- </div>
352
- </td>
353
- <!-- priority -->
354
- <td v-if="isColumnActive('priority')" class="px-4 py-3">
355
- <PriorityBadge :priority="ticket.priority" />
356
- </td>
357
- <!-- assignee -->
358
- <td v-if="isColumnActive('assignee')" class="px-4 py-3 text-sm text-neutral-500">
359
- {{ ticket.assignee?.name || 'Unassigned' }}
360
- </td>
361
- <!-- department -->
362
- <td v-if="isColumnActive('department')" class="px-4 py-3 text-sm text-neutral-500">
363
- {{ ticket.department?.name || '—' }}
364
- </td>
365
- <!-- updated (last reply) -->
366
- <td v-if="isColumnActive('updated')" class="whitespace-nowrap px-4 py-3 text-sm text-neutral-600">
367
- <template v-if="ticket.latest_reply || ticket.last_reply_at">
368
- <div class="text-neutral-400">
369
- {{ timeAgo(ticket.latest_reply?.created_at || ticket.last_reply_at) }}
370
- </div>
371
- <div class="text-xs text-neutral-600">
372
- {{ ticket.latest_reply?.author?.name || ticket.last_reply_author || '' }}
373
- </div>
374
- </template>
375
- <span v-else class="text-neutral-700">—</span>
376
- </td>
377
- <!-- created -->
378
- <td v-if="isColumnActive('created')" class="whitespace-nowrap px-4 py-3 text-sm text-neutral-500">
379
- {{ timeAgo(ticket.created_at) }}
380
- </td>
381
- <!-- sla -->
382
- <td v-if="isColumnActive('sla')" class="px-4 py-3 text-sm">
383
- <span
384
- v-if="slaClass(ticket)"
385
- :class="[
386
- 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium text-white',
387
- slaClass(ticket),
388
- ]"
389
- >
390
- {{ slaLabel(ticket) }}
391
- </span>
392
- <span v-else class="text-neutral-700">—</span>
393
- </td>
394
- <!-- tags -->
395
- <td v-if="isColumnActive('tags')" class="px-4 py-3 text-sm">
396
- <div class="flex flex-wrap gap-1">
397
- <span
398
- v-for="tag in ticket.tags || []"
399
- :key="tag.id || tag"
400
- class="inline-block rounded-full bg-white/[0.06] px-2 py-0.5 text-xs text-neutral-400"
401
- >
402
- {{ tag.name || tag }}
403
- </span>
404
- <span v-if="!ticket.tags?.length" class="text-neutral-700">—</span>
405
- </div>
406
- </td>
407
- <!-- category (ticket type) -->
408
- <td v-if="isColumnActive('category')" class="px-4 py-3 text-sm">
409
- <span
410
- v-if="ticket.ticket_type"
411
- :class="[
412
- 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
413
- ticketTypeBadgeClass(ticket.ticket_type, true),
414
- ]"
415
- >
416
- {{ ticketTypeLabel(ticket.ticket_type) }}
417
- </span>
418
- <span v-else class="text-neutral-700">—</span>
419
- </td>
420
- <!-- spacer for gear column -->
421
- <td class="w-10 px-2 py-3"></td>
422
- </tr>
423
- <tr v-if="!tickets.data?.length">
424
- <td :colspan="colCount + 1" class="px-4 py-8 text-center text-sm text-neutral-600">
425
- No tickets found.
426
- </td>
427
- </tr>
428
- </tbody>
429
- </table>
430
- </div>
431
-
432
- <!-- Light mode -->
433
- <div v-else class="overflow-hidden rounded-lg border border-gray-200 bg-white">
434
- <table class="min-w-full divide-y divide-gray-200">
435
- <thead class="bg-gray-50">
436
- <tr>
437
- <th v-if="selectable" scope="col" class="w-10 px-3 py-3">
438
- <input
439
- type="checkbox"
440
- :checked="allSelected"
441
- aria-label="Select all tickets"
442
- class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
443
- @change="toggleAll"
444
- />
445
- </th>
446
- <th
447
- v-for="col in visibleColumnDefs"
448
- :key="col.key"
449
- scope="col"
450
- class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"
451
- >
452
- {{ col.label }}
453
- </th>
454
- <th scope="col" class="w-10 px-2 py-3 text-right">
455
- <div class="relative inline-block" data-column-picker>
456
- <button
457
- aria-label="Configure columns"
458
- :class="[
459
- 'inline-flex items-center justify-center h-7 w-7 rounded-md transition-colors',
460
- 'text-gray-400 hover:text-gray-600 hover:bg-gray-100',
461
- ]"
462
- @click.stop="showColumnPicker = !showColumnPicker"
463
- >
464
- <svg
465
- xmlns="http://www.w3.org/2000/svg"
466
- class="h-4 w-4"
467
- viewBox="0 0 20 20"
468
- fill="currentColor"
469
- >
470
- <path
471
- fill-rule="evenodd"
472
- d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
473
- clip-rule="evenodd"
474
- />
475
- </svg>
476
- </button>
477
- <div
478
- v-if="showColumnPicker"
479
- role="menu"
480
- aria-label="Column visibility"
481
- class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-xl"
482
- >
483
- <label
484
- v-for="col in ALL_COLUMNS"
485
- :key="col.key"
486
- role="menuitemcheckbox"
487
- :aria-checked="isColumnActive(col.key)"
488
- class="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
489
- >
490
- <input
491
- type="checkbox"
492
- :checked="isColumnActive(col.key)"
493
- class="h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
494
- @change="toggleColumn(col.key)"
495
- />
496
- {{ col.label }}
497
- </label>
498
- </div>
499
- </div>
500
- </th>
501
- </tr>
502
- </thead>
503
- <tbody class="divide-y divide-gray-200">
504
- <tr
505
- v-for="(ticket, index) in tickets.data"
506
- :key="ticket.id"
507
- :class="['group', navigable && focusedIndex === index ? 'bg-indigo-50' : 'hover:bg-gray-50']"
508
- >
509
- <td v-if="selectable" class="w-10 px-3 py-3">
510
- <input
511
- type="checkbox"
512
- :checked="selectedIds.includes(ticket.id)"
513
- aria-label="Select ticket"
514
- class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
515
- @change="toggleOne(ticket.id)"
516
- />
517
- </td>
518
- <!-- reference -->
519
- <td
520
- v-if="isColumnActive('reference')"
521
- class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900"
522
- >
523
- <div class="flex items-center gap-2">
524
- <span
525
- v-if="slaClass(ticket)"
526
- :class="['h-2 w-2 shrink-0 rounded-full', slaClass(ticket)]"
527
- ></span>
528
- <Link
529
- :href="route(`${routePrefix}.show`, ticket.reference)"
530
- class="text-indigo-600 hover:text-indigo-900"
531
- >
532
- {{ ticket.reference }}
533
- </Link>
534
- </div>
535
- </td>
536
- <!-- subject -->
537
- <td v-if="isColumnActive('subject')" class="px-4 py-3 text-sm text-gray-900">
538
- {{ truncate(ticket.subject) }}
539
- </td>
540
- <!-- requester -->
541
- <td v-if="isColumnActive('requester')" class="px-4 py-3 text-sm text-gray-500">
542
- <div>{{ ticket.requester?.name || ticket.requester_name || 'Unknown' }}</div>
543
- <div class="truncate text-xs text-gray-400" style="max-width: 150px">
544
- {{ ticket.requester?.email || ticket.requester_email || '' }}
545
- </div>
546
- </td>
547
- <!-- status -->
548
- <td v-if="isColumnActive('status')" class="px-4 py-3">
549
- <div class="flex items-center gap-1.5">
550
- <StatusBadge :status="ticket.status" />
551
- <span
552
- v-if="showLiveBadge && ticket.channel === 'chat' && ticket.status === 'live'"
553
- class="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400 ring-1 ring-emerald-500/20"
554
- >
555
- <span class="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400"></span>
556
- Live
557
- </span>
558
- <span
559
- v-if="ticket.snoozed_until && new Date(ticket.snoozed_until) > Date.now()"
560
- class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 ring-1 ring-amber-500/20"
561
- :title="'Snoozed until ' + new Date(ticket.snoozed_until).toLocaleString()"
562
- >
563
- <svg
564
- xmlns="http://www.w3.org/2000/svg"
565
- class="h-3 w-3"
566
- viewBox="0 0 20 20"
567
- fill="currentColor"
568
- >
569
- <path
570
- fill-rule="evenodd"
571
- d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.828a1 1 0 101.415-1.414L11 9.586V6z"
572
- clip-rule="evenodd"
573
- />
574
- </svg>
575
- Snoozed
576
- </span>
577
- </div>
578
- </td>
579
- <!-- priority -->
580
- <td v-if="isColumnActive('priority')" class="px-4 py-3">
581
- <PriorityBadge :priority="ticket.priority" />
582
- </td>
583
- <!-- assignee -->
584
- <td v-if="isColumnActive('assignee')" class="px-4 py-3 text-sm text-gray-500">
585
- {{ ticket.assignee?.name || 'Unassigned' }}
586
- </td>
587
- <!-- department -->
588
- <td v-if="isColumnActive('department')" class="px-4 py-3 text-sm text-gray-500">
589
- {{ ticket.department?.name || '—' }}
590
- </td>
591
- <!-- updated (last reply) -->
592
- <td v-if="isColumnActive('updated')" class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
593
- <template v-if="ticket.latest_reply || ticket.last_reply_at">
594
- <div>{{ timeAgo(ticket.latest_reply?.created_at || ticket.last_reply_at) }}</div>
595
- <div class="text-xs text-gray-400">
596
- {{ ticket.latest_reply?.author?.name || ticket.last_reply_author || '' }}
597
- </div>
598
- </template>
599
- <span v-else class="text-gray-400">—</span>
600
- </td>
601
- <!-- created -->
602
- <td v-if="isColumnActive('created')" class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
603
- {{ timeAgo(ticket.created_at) }}
604
- </td>
605
- <!-- sla -->
606
- <td v-if="isColumnActive('sla')" class="px-4 py-3 text-sm">
607
- <span
608
- v-if="slaClass(ticket)"
609
- :class="[
610
- 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium text-white',
611
- slaClass(ticket),
612
- ]"
613
- >
614
- {{ slaLabel(ticket) }}
615
- </span>
616
- <span v-else class="text-gray-400">—</span>
617
- </td>
618
- <!-- tags -->
619
- <td v-if="isColumnActive('tags')" class="px-4 py-3 text-sm">
620
- <div class="flex flex-wrap gap-1">
621
- <span
622
- v-for="tag in ticket.tags || []"
623
- :key="tag.id || tag"
624
- class="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
625
- >
626
- {{ tag.name || tag }}
627
- </span>
628
- <span v-if="!ticket.tags?.length" class="text-gray-400">—</span>
629
- </div>
630
- </td>
631
- <!-- category (ticket type) -->
632
- <td v-if="isColumnActive('category')" class="px-4 py-3 text-sm">
633
- <span
634
- v-if="ticket.ticket_type"
635
- :class="[
636
- 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
637
- ticketTypeBadgeClass(ticket.ticket_type, false),
638
- ]"
639
- >
640
- {{ ticketTypeLabel(ticket.ticket_type) }}
641
- </span>
642
- <span v-else class="text-gray-400">—</span>
643
- </td>
644
- <!-- spacer for gear column -->
645
- <td class="w-10 px-2 py-3"></td>
646
- </tr>
647
- <tr v-if="!tickets.data?.length">
648
- <td :colspan="colCount + 1" class="px-4 py-8 text-center text-sm text-gray-500">
649
- No tickets found.
650
- </td>
651
- </tr>
652
- </tbody>
653
- </table>
654
- </div>
655
- </template>
1
+ <script setup>
2
+ /* global localStorage */
3
+ import { inject, computed, ref, watch } from 'vue';
4
+ import StatusBadge from './StatusBadge.vue';
5
+ import PriorityBadge from './PriorityBadge.vue';
6
+ import { Link, router } from '@inertiajs/vue3';
7
+ import { TICKET_TYPE_COLORS } from '../utils/constants';
8
+ import { timeAgo, slaClass } from '../utils/formatting';
9
+
10
+ const STORAGE_KEY = 'escalated-ticket-columns';
11
+
12
+ const ALL_COLUMNS = [
13
+ { key: 'reference', label: 'Reference' },
14
+ { key: 'subject', label: 'Subject' },
15
+ { key: 'requester', label: 'Requester' },
16
+ { key: 'status', label: 'Status' },
17
+ { key: 'priority', label: 'Priority' },
18
+ { key: 'assignee', label: 'Assignee' },
19
+ { key: 'department', label: 'Department' },
20
+ { key: 'updated', label: 'Last Reply' },
21
+ { key: 'created', label: 'Created' },
22
+ { key: 'sla', label: 'SLA' },
23
+ { key: 'tags', label: 'Tags' },
24
+ { key: 'category', label: 'Category' },
25
+ ];
26
+
27
+ const props = defineProps({
28
+ tickets: { type: Object, required: true },
29
+ routePrefix: { type: String, default: 'escalated.customer.tickets' },
30
+ /** @deprecated Use `columns` prop instead */
31
+ showAssignee: { type: Boolean, default: false },
32
+ columns: {
33
+ type: Array,
34
+ default: () => ['reference', 'subject', 'requester', 'status', 'priority', 'updated'],
35
+ },
36
+ selectable: { type: Boolean, default: false },
37
+ selectedIds: { type: Array, default: () => [] },
38
+ assignRoute: { type: String, default: '' },
39
+ navigable: { type: Boolean, default: false },
40
+ showLiveBadge: { type: Boolean, default: false },
41
+ });
42
+
43
+ const emit = defineEmits(['update:selectedIds']);
44
+ const escDark = inject(
45
+ 'esc-dark',
46
+ computed(() => false),
47
+ );
48
+ const focusedIndex = ref(-1);
49
+ const showColumnPicker = ref(false);
50
+
51
+ // Resolve initial columns: merge prop with deprecated showAssignee, then override from localStorage
52
+ function resolveDefaultColumns() {
53
+ const base = [...props.columns];
54
+ if (props.showAssignee && !base.includes('assignee')) {
55
+ const prioIdx = base.indexOf('priority');
56
+ if (prioIdx >= 0) {
57
+ base.splice(prioIdx + 1, 0, 'assignee');
58
+ } else {
59
+ base.push('assignee');
60
+ }
61
+ }
62
+ return base;
63
+ }
64
+
65
+ function loadSavedColumns() {
66
+ try {
67
+ const saved = localStorage.getItem(STORAGE_KEY);
68
+ if (saved) {
69
+ const parsed = JSON.parse(saved);
70
+ if (Array.isArray(parsed) && parsed.length > 0) return parsed;
71
+ }
72
+ } catch {
73
+ // ignore
74
+ }
75
+ return null;
76
+ }
77
+
78
+ const activeColumns = ref(loadSavedColumns() || resolveDefaultColumns());
79
+
80
+ function saveColumns() {
81
+ try {
82
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(activeColumns.value));
83
+ } catch {
84
+ // ignore
85
+ }
86
+ }
87
+
88
+ function toggleColumn(key) {
89
+ const idx = activeColumns.value.indexOf(key);
90
+ if (idx >= 0) {
91
+ if (activeColumns.value.length <= 1) return; // must keep at least one
92
+ activeColumns.value.splice(idx, 1);
93
+ } else {
94
+ activeColumns.value.push(key);
95
+ }
96
+ saveColumns();
97
+ }
98
+
99
+ function isColumnActive(key) {
100
+ return activeColumns.value.includes(key);
101
+ }
102
+
103
+ const visibleColumnDefs = computed(() => {
104
+ return ALL_COLUMNS.filter((c) => activeColumns.value.includes(c.key));
105
+ });
106
+
107
+ // Close column picker on outside click
108
+ function onClickOutside(e) {
109
+ if (!e.target.closest('[data-column-picker]')) {
110
+ showColumnPicker.value = false;
111
+ }
112
+ }
113
+
114
+ watch(showColumnPicker, (val) => {
115
+ if (val) {
116
+ document.addEventListener('click', onClickOutside, true);
117
+ } else {
118
+ document.removeEventListener('click', onClickOutside, true);
119
+ }
120
+ });
121
+
122
+ function moveDown() {
123
+ if (!props.tickets.data?.length) return;
124
+ focusedIndex.value = Math.min(focusedIndex.value + 1, props.tickets.data.length - 1);
125
+ }
126
+ function moveUp() {
127
+ if (!props.tickets.data?.length) return;
128
+ focusedIndex.value = Math.max(focusedIndex.value - 1, 0);
129
+ }
130
+ function toggleSelectFocused() {
131
+ if (focusedIndex.value < 0 || !props.tickets.data?.length) return;
132
+ toggleOne(props.tickets.data[focusedIndex.value].id);
133
+ }
134
+ function openFocused() {
135
+ if (focusedIndex.value < 0 || !props.tickets.data?.length) return;
136
+ const ticket = props.tickets.data[focusedIndex.value];
137
+ router.visit(route(`${props.routePrefix}.show`, ticket.reference));
138
+ }
139
+
140
+ defineExpose({ moveDown, moveUp, toggleSelectFocused, openFocused });
141
+
142
+ const allSelected = computed(() => {
143
+ if (!props.tickets.data?.length) return false;
144
+ return props.tickets.data.every((t) => props.selectedIds.includes(t.id));
145
+ });
146
+
147
+ function toggleAll() {
148
+ if (allSelected.value) {
149
+ emit('update:selectedIds', []);
150
+ } else {
151
+ emit(
152
+ 'update:selectedIds',
153
+ props.tickets.data.map((t) => t.id),
154
+ );
155
+ }
156
+ }
157
+
158
+ function toggleOne(id) {
159
+ const ids = [...props.selectedIds];
160
+ const idx = ids.indexOf(id);
161
+ if (idx >= 0) ids.splice(idx, 1);
162
+ else ids.push(id);
163
+ emit('update:selectedIds', ids);
164
+ }
165
+
166
+ function slaLabel(ticket) {
167
+ if (ticket.sla_first_response_breached || ticket.sla_resolution_breached) return 'Breached';
168
+ if (ticket.first_response_due_at || ticket.resolution_due_at) {
169
+ const due = ticket.resolution_due_at || ticket.first_response_due_at;
170
+ const mins = Math.round((new Date(due) - Date.now()) / 60000);
171
+ if (mins < 0) return 'Overdue';
172
+ if (mins < 60) return `${mins}m left`;
173
+ const hrs = Math.floor(mins / 60);
174
+ if (hrs < 24) return `${hrs}h left`;
175
+ return `${Math.floor(hrs / 24)}d left`;
176
+ }
177
+ return '—';
178
+ }
179
+
180
+ function ticketTypeLabel(type) {
181
+ const entry = TICKET_TYPE_COLORS[type];
182
+ if (!entry) return type || '—';
183
+ // Derive label from key
184
+ return type.charAt(0).toUpperCase() + type.slice(1);
185
+ }
186
+
187
+ function ticketTypeBadgeClass(type, dark) {
188
+ const entry = TICKET_TYPE_COLORS[type];
189
+ if (!entry) return '';
190
+ return dark ? entry.dark : entry.light;
191
+ }
192
+
193
+ function truncate(str, len = 60) {
194
+ if (!str) return '';
195
+ return str.length > len ? str.slice(0, len) + '...' : str;
196
+ }
197
+
198
+ const colCount = computed(() => {
199
+ let count = visibleColumnDefs.value.length;
200
+ if (props.selectable) count++;
201
+ return count;
202
+ });
203
+ </script>
204
+
205
+ <template>
206
+ <!-- Dark mode -->
207
+ <div v-if="escDark" class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
208
+ <table class="min-w-full divide-y divide-white/[0.06]">
209
+ <thead>
210
+ <tr class="bg-white/[0.02]">
211
+ <th v-if="selectable" scope="col" class="w-10 px-3 py-3">
212
+ <input
213
+ type="checkbox"
214
+ :checked="allSelected"
215
+ aria-label="Select all tickets"
216
+ class="h-4 w-4 rounded border-white/20 bg-neutral-950 text-cyan-500 focus:ring-cyan-500/20"
217
+ @change="toggleAll"
218
+ />
219
+ </th>
220
+ <th
221
+ v-for="col in visibleColumnDefs"
222
+ :key="col.key"
223
+ scope="col"
224
+ class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500"
225
+ >
226
+ {{ col.label }}
227
+ </th>
228
+ <th scope="col" class="w-10 px-2 py-3 text-right">
229
+ <div class="relative inline-block" data-column-picker>
230
+ <button
231
+ aria-label="Configure columns"
232
+ :class="[
233
+ 'inline-flex items-center justify-center h-7 w-7 rounded-md transition-colors',
234
+ 'text-neutral-500 hover:text-neutral-300 hover:bg-white/[0.06]',
235
+ ]"
236
+ @click.stop="showColumnPicker = !showColumnPicker"
237
+ >
238
+ <svg
239
+ xmlns="http://www.w3.org/2000/svg"
240
+ class="h-4 w-4"
241
+ viewBox="0 0 20 20"
242
+ fill="currentColor"
243
+ >
244
+ <path
245
+ fill-rule="evenodd"
246
+ d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
247
+ clip-rule="evenodd"
248
+ />
249
+ </svg>
250
+ </button>
251
+ <div
252
+ v-if="showColumnPicker"
253
+ role="menu"
254
+ aria-label="Column visibility"
255
+ class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-white/[0.08] bg-neutral-800 py-1 shadow-xl"
256
+ >
257
+ <label
258
+ v-for="col in ALL_COLUMNS"
259
+ :key="col.key"
260
+ role="menuitemcheckbox"
261
+ :aria-checked="isColumnActive(col.key)"
262
+ class="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm text-neutral-300 hover:bg-white/[0.06]"
263
+ >
264
+ <input
265
+ type="checkbox"
266
+ :checked="isColumnActive(col.key)"
267
+ class="h-3.5 w-3.5 rounded border-white/20 bg-neutral-900 text-cyan-500 focus:ring-cyan-500/20"
268
+ @change="toggleColumn(col.key)"
269
+ />
270
+ {{ col.label }}
271
+ </label>
272
+ </div>
273
+ </div>
274
+ </th>
275
+ </tr>
276
+ </thead>
277
+ <tbody class="divide-y divide-white/[0.04]">
278
+ <tr
279
+ v-for="(ticket, index) in tickets.data"
280
+ :key="ticket.id"
281
+ :class="[
282
+ 'group transition-colors',
283
+ navigable && focusedIndex === index ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]',
284
+ ]"
285
+ >
286
+ <td v-if="selectable" class="w-10 px-3 py-3">
287
+ <input
288
+ type="checkbox"
289
+ :checked="selectedIds.includes(ticket.id)"
290
+ aria-label="Select ticket"
291
+ class="h-4 w-4 rounded border-white/20 bg-neutral-950 text-cyan-500 focus:ring-cyan-500/20"
292
+ @change="toggleOne(ticket.id)"
293
+ />
294
+ </td>
295
+ <!-- reference -->
296
+ <td v-if="isColumnActive('reference')" class="whitespace-nowrap px-4 py-3 text-sm font-medium">
297
+ <div class="flex items-center gap-2">
298
+ <span
299
+ v-if="slaClass(ticket)"
300
+ :class="['h-2 w-2 shrink-0 rounded-full', slaClass(ticket)]"
301
+ ></span>
302
+ <Link
303
+ :href="route(`${routePrefix}.show`, ticket.reference)"
304
+ class="text-white hover:text-neutral-300"
305
+ >
306
+ {{ ticket.reference }}
307
+ </Link>
308
+ </div>
309
+ </td>
310
+ <!-- subject -->
311
+ <td v-if="isColumnActive('subject')" class="px-4 py-3 text-sm text-neutral-300">
312
+ {{ truncate(ticket.subject) }}
313
+ </td>
314
+ <!-- requester -->
315
+ <td v-if="isColumnActive('requester')" class="px-4 py-3 text-sm text-neutral-400">
316
+ <div>{{ ticket.requester?.name || ticket.requester_name || 'Unknown' }}</div>
317
+ <div class="truncate text-xs text-neutral-600" style="max-width: 150px">
318
+ {{ ticket.requester?.email || ticket.requester_email || '' }}
319
+ </div>
320
+ </td>
321
+ <!-- status -->
322
+ <td v-if="isColumnActive('status')" class="px-4 py-3">
323
+ <div class="flex items-center gap-1.5">
324
+ <StatusBadge :status="ticket.status" />
325
+ <span
326
+ v-if="showLiveBadge && ticket.channel === 'chat' && ticket.status === 'live'"
327
+ class="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400 ring-1 ring-emerald-500/20"
328
+ >
329
+ <span class="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400"></span>
330
+ Live
331
+ </span>
332
+ <span
333
+ v-if="ticket.snoozed_until && new Date(ticket.snoozed_until) > Date.now()"
334
+ class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 ring-1 ring-amber-500/20"
335
+ :title="'Snoozed until ' + new Date(ticket.snoozed_until).toLocaleString()"
336
+ >
337
+ <svg
338
+ xmlns="http://www.w3.org/2000/svg"
339
+ class="h-3 w-3"
340
+ viewBox="0 0 20 20"
341
+ fill="currentColor"
342
+ >
343
+ <path
344
+ fill-rule="evenodd"
345
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.828a1 1 0 101.415-1.414L11 9.586V6z"
346
+ clip-rule="evenodd"
347
+ />
348
+ </svg>
349
+ Snoozed
350
+ </span>
351
+ </div>
352
+ </td>
353
+ <!-- priority -->
354
+ <td v-if="isColumnActive('priority')" class="px-4 py-3">
355
+ <PriorityBadge :priority="ticket.priority" />
356
+ </td>
357
+ <!-- assignee -->
358
+ <td v-if="isColumnActive('assignee')" class="px-4 py-3 text-sm text-neutral-500">
359
+ {{ ticket.assignee?.name || 'Unassigned' }}
360
+ </td>
361
+ <!-- department -->
362
+ <td v-if="isColumnActive('department')" class="px-4 py-3 text-sm text-neutral-500">
363
+ {{ ticket.department?.name || '—' }}
364
+ </td>
365
+ <!-- updated (last reply) -->
366
+ <td v-if="isColumnActive('updated')" class="whitespace-nowrap px-4 py-3 text-sm text-neutral-600">
367
+ <template v-if="ticket.latest_reply || ticket.last_reply_at">
368
+ <div class="text-neutral-400">
369
+ {{ timeAgo(ticket.latest_reply?.created_at || ticket.last_reply_at) }}
370
+ </div>
371
+ <div class="text-xs text-neutral-600">
372
+ {{ ticket.latest_reply?.author?.name || ticket.last_reply_author || '' }}
373
+ </div>
374
+ </template>
375
+ <span v-else class="text-neutral-700">—</span>
376
+ </td>
377
+ <!-- created -->
378
+ <td v-if="isColumnActive('created')" class="whitespace-nowrap px-4 py-3 text-sm text-neutral-500">
379
+ {{ timeAgo(ticket.created_at) }}
380
+ </td>
381
+ <!-- sla -->
382
+ <td v-if="isColumnActive('sla')" class="px-4 py-3 text-sm">
383
+ <span
384
+ v-if="slaClass(ticket)"
385
+ :class="[
386
+ 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium text-white',
387
+ slaClass(ticket),
388
+ ]"
389
+ >
390
+ {{ slaLabel(ticket) }}
391
+ </span>
392
+ <span v-else class="text-neutral-700">—</span>
393
+ </td>
394
+ <!-- tags -->
395
+ <td v-if="isColumnActive('tags')" class="px-4 py-3 text-sm">
396
+ <div class="flex flex-wrap gap-1">
397
+ <span
398
+ v-for="tag in ticket.tags || []"
399
+ :key="tag.id || tag"
400
+ class="inline-block rounded-full bg-white/[0.06] px-2 py-0.5 text-xs text-neutral-400"
401
+ >
402
+ {{ tag.name || tag }}
403
+ </span>
404
+ <span v-if="!ticket.tags?.length" class="text-neutral-700">—</span>
405
+ </div>
406
+ </td>
407
+ <!-- category (ticket type) -->
408
+ <td v-if="isColumnActive('category')" class="px-4 py-3 text-sm">
409
+ <span
410
+ v-if="ticket.ticket_type"
411
+ :class="[
412
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
413
+ ticketTypeBadgeClass(ticket.ticket_type, true),
414
+ ]"
415
+ >
416
+ {{ ticketTypeLabel(ticket.ticket_type) }}
417
+ </span>
418
+ <span v-else class="text-neutral-700">—</span>
419
+ </td>
420
+ <!-- spacer for gear column -->
421
+ <td class="w-10 px-2 py-3"></td>
422
+ </tr>
423
+ <tr v-if="!tickets.data?.length">
424
+ <td :colspan="colCount + 1" class="px-4 py-8 text-center text-sm text-neutral-600">
425
+ No tickets found.
426
+ </td>
427
+ </tr>
428
+ </tbody>
429
+ </table>
430
+ </div>
431
+
432
+ <!-- Light mode -->
433
+ <div v-else class="overflow-hidden rounded-lg border border-gray-200 bg-white">
434
+ <table class="min-w-full divide-y divide-gray-200">
435
+ <thead class="bg-gray-50">
436
+ <tr>
437
+ <th v-if="selectable" scope="col" class="w-10 px-3 py-3">
438
+ <input
439
+ type="checkbox"
440
+ :checked="allSelected"
441
+ aria-label="Select all tickets"
442
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
443
+ @change="toggleAll"
444
+ />
445
+ </th>
446
+ <th
447
+ v-for="col in visibleColumnDefs"
448
+ :key="col.key"
449
+ scope="col"
450
+ class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"
451
+ >
452
+ {{ col.label }}
453
+ </th>
454
+ <th scope="col" class="w-10 px-2 py-3 text-right">
455
+ <div class="relative inline-block" data-column-picker>
456
+ <button
457
+ aria-label="Configure columns"
458
+ :class="[
459
+ 'inline-flex items-center justify-center h-7 w-7 rounded-md transition-colors',
460
+ 'text-gray-400 hover:text-gray-600 hover:bg-gray-100',
461
+ ]"
462
+ @click.stop="showColumnPicker = !showColumnPicker"
463
+ >
464
+ <svg
465
+ xmlns="http://www.w3.org/2000/svg"
466
+ class="h-4 w-4"
467
+ viewBox="0 0 20 20"
468
+ fill="currentColor"
469
+ >
470
+ <path
471
+ fill-rule="evenodd"
472
+ d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
473
+ clip-rule="evenodd"
474
+ />
475
+ </svg>
476
+ </button>
477
+ <div
478
+ v-if="showColumnPicker"
479
+ role="menu"
480
+ aria-label="Column visibility"
481
+ class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-xl"
482
+ >
483
+ <label
484
+ v-for="col in ALL_COLUMNS"
485
+ :key="col.key"
486
+ role="menuitemcheckbox"
487
+ :aria-checked="isColumnActive(col.key)"
488
+ class="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
489
+ >
490
+ <input
491
+ type="checkbox"
492
+ :checked="isColumnActive(col.key)"
493
+ class="h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
494
+ @change="toggleColumn(col.key)"
495
+ />
496
+ {{ col.label }}
497
+ </label>
498
+ </div>
499
+ </div>
500
+ </th>
501
+ </tr>
502
+ </thead>
503
+ <tbody class="divide-y divide-gray-200">
504
+ <tr
505
+ v-for="(ticket, index) in tickets.data"
506
+ :key="ticket.id"
507
+ :class="['group', navigable && focusedIndex === index ? 'bg-indigo-50' : 'hover:bg-gray-50']"
508
+ >
509
+ <td v-if="selectable" class="w-10 px-3 py-3">
510
+ <input
511
+ type="checkbox"
512
+ :checked="selectedIds.includes(ticket.id)"
513
+ aria-label="Select ticket"
514
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
515
+ @change="toggleOne(ticket.id)"
516
+ />
517
+ </td>
518
+ <!-- reference -->
519
+ <td
520
+ v-if="isColumnActive('reference')"
521
+ class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900"
522
+ >
523
+ <div class="flex items-center gap-2">
524
+ <span
525
+ v-if="slaClass(ticket)"
526
+ :class="['h-2 w-2 shrink-0 rounded-full', slaClass(ticket)]"
527
+ ></span>
528
+ <Link
529
+ :href="route(`${routePrefix}.show`, ticket.reference)"
530
+ class="text-indigo-600 hover:text-indigo-900"
531
+ >
532
+ {{ ticket.reference }}
533
+ </Link>
534
+ </div>
535
+ </td>
536
+ <!-- subject -->
537
+ <td v-if="isColumnActive('subject')" class="px-4 py-3 text-sm text-gray-900">
538
+ {{ truncate(ticket.subject) }}
539
+ </td>
540
+ <!-- requester -->
541
+ <td v-if="isColumnActive('requester')" class="px-4 py-3 text-sm text-gray-500">
542
+ <div>{{ ticket.requester?.name || ticket.requester_name || 'Unknown' }}</div>
543
+ <div class="truncate text-xs text-gray-400" style="max-width: 150px">
544
+ {{ ticket.requester?.email || ticket.requester_email || '' }}
545
+ </div>
546
+ </td>
547
+ <!-- status -->
548
+ <td v-if="isColumnActive('status')" class="px-4 py-3">
549
+ <div class="flex items-center gap-1.5">
550
+ <StatusBadge :status="ticket.status" />
551
+ <span
552
+ v-if="showLiveBadge && ticket.channel === 'chat' && ticket.status === 'live'"
553
+ class="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400 ring-1 ring-emerald-500/20"
554
+ >
555
+ <span class="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400"></span>
556
+ Live
557
+ </span>
558
+ <span
559
+ v-if="ticket.snoozed_until && new Date(ticket.snoozed_until) > Date.now()"
560
+ class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 ring-1 ring-amber-500/20"
561
+ :title="'Snoozed until ' + new Date(ticket.snoozed_until).toLocaleString()"
562
+ >
563
+ <svg
564
+ xmlns="http://www.w3.org/2000/svg"
565
+ class="h-3 w-3"
566
+ viewBox="0 0 20 20"
567
+ fill="currentColor"
568
+ >
569
+ <path
570
+ fill-rule="evenodd"
571
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.828a1 1 0 101.415-1.414L11 9.586V6z"
572
+ clip-rule="evenodd"
573
+ />
574
+ </svg>
575
+ Snoozed
576
+ </span>
577
+ </div>
578
+ </td>
579
+ <!-- priority -->
580
+ <td v-if="isColumnActive('priority')" class="px-4 py-3">
581
+ <PriorityBadge :priority="ticket.priority" />
582
+ </td>
583
+ <!-- assignee -->
584
+ <td v-if="isColumnActive('assignee')" class="px-4 py-3 text-sm text-gray-500">
585
+ {{ ticket.assignee?.name || 'Unassigned' }}
586
+ </td>
587
+ <!-- department -->
588
+ <td v-if="isColumnActive('department')" class="px-4 py-3 text-sm text-gray-500">
589
+ {{ ticket.department?.name || '—' }}
590
+ </td>
591
+ <!-- updated (last reply) -->
592
+ <td v-if="isColumnActive('updated')" class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
593
+ <template v-if="ticket.latest_reply || ticket.last_reply_at">
594
+ <div>{{ timeAgo(ticket.latest_reply?.created_at || ticket.last_reply_at) }}</div>
595
+ <div class="text-xs text-gray-400">
596
+ {{ ticket.latest_reply?.author?.name || ticket.last_reply_author || '' }}
597
+ </div>
598
+ </template>
599
+ <span v-else class="text-gray-400">—</span>
600
+ </td>
601
+ <!-- created -->
602
+ <td v-if="isColumnActive('created')" class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
603
+ {{ timeAgo(ticket.created_at) }}
604
+ </td>
605
+ <!-- sla -->
606
+ <td v-if="isColumnActive('sla')" class="px-4 py-3 text-sm">
607
+ <span
608
+ v-if="slaClass(ticket)"
609
+ :class="[
610
+ 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium text-white',
611
+ slaClass(ticket),
612
+ ]"
613
+ >
614
+ {{ slaLabel(ticket) }}
615
+ </span>
616
+ <span v-else class="text-gray-400">—</span>
617
+ </td>
618
+ <!-- tags -->
619
+ <td v-if="isColumnActive('tags')" class="px-4 py-3 text-sm">
620
+ <div class="flex flex-wrap gap-1">
621
+ <span
622
+ v-for="tag in ticket.tags || []"
623
+ :key="tag.id || tag"
624
+ class="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
625
+ >
626
+ {{ tag.name || tag }}
627
+ </span>
628
+ <span v-if="!ticket.tags?.length" class="text-gray-400">—</span>
629
+ </div>
630
+ </td>
631
+ <!-- category (ticket type) -->
632
+ <td v-if="isColumnActive('category')" class="px-4 py-3 text-sm">
633
+ <span
634
+ v-if="ticket.ticket_type"
635
+ :class="[
636
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
637
+ ticketTypeBadgeClass(ticket.ticket_type, false),
638
+ ]"
639
+ >
640
+ {{ ticketTypeLabel(ticket.ticket_type) }}
641
+ </span>
642
+ <span v-else class="text-gray-400">—</span>
643
+ </td>
644
+ <!-- spacer for gear column -->
645
+ <td class="w-10 px-2 py-3"></td>
646
+ </tr>
647
+ <tr v-if="!tickets.data?.length">
648
+ <td :colspan="colCount + 1" class="px-4 py-8 text-center text-sm text-gray-500">
649
+ No tickets found.
650
+ </td>
651
+ </tr>
652
+ </tbody>
653
+ </table>
654
+ </div>
655
+ </template>