@escalated-dev/escalated 0.6.0 → 0.7.1

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 (187) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +125 -2
  3. package/package.json +40 -5
  4. package/src/components/ActiveChatsPanel.vue +246 -0
  5. package/src/components/ActivityTimeline.stories.js +55 -0
  6. package/src/components/ActivityTimeline.vue +83 -66
  7. package/src/components/AdminDashboard.stories.js +1423 -0
  8. package/src/components/AgentLoadIndicator.stories.js +37 -0
  9. package/src/components/AgentLoadIndicator.vue +49 -0
  10. package/src/components/AgentPerformanceCard.vue +44 -0
  11. package/src/components/ArticleEditor.vue +82 -0
  12. package/src/components/AssigneeSelect.vue +37 -23
  13. package/src/components/AttachmentList.vue +65 -37
  14. package/src/components/AuditLogEntry.vue +95 -0
  15. package/src/components/BulkActionBar.vue +166 -97
  16. package/src/components/CategoryTree.vue +89 -0
  17. package/src/components/ChartWidget.vue +46 -0
  18. package/src/components/ChatActionBar.vue +226 -0
  19. package/src/components/ChatBubble.vue +162 -0
  20. package/src/components/ChatComposer.vue +308 -0
  21. package/src/components/ChatQueue.vue +222 -0
  22. package/src/components/ChatThread.vue +197 -0
  23. package/src/components/CollisionWarning.stories.js +30 -0
  24. package/src/components/CollisionWarning.vue +68 -0
  25. package/src/components/ConditionalFieldRules.vue +135 -0
  26. package/src/components/ContextPanel.vue +66 -0
  27. package/src/components/ContextPanelSection.vue +65 -0
  28. package/src/components/CustomFieldRenderer.vue +159 -0
  29. package/src/components/EscalatedLayout.vue +424 -70
  30. package/src/components/FileDropzone.vue +57 -41
  31. package/src/components/FollowButton.stories.js +30 -0
  32. package/src/components/FollowButton.vue +88 -63
  33. package/src/components/KeyboardShortcutHelp.vue +213 -123
  34. package/src/components/KnowledgePanel.vue +122 -0
  35. package/src/components/KpiCard.stories.js +32 -0
  36. package/src/components/KpiCard.vue +88 -0
  37. package/src/components/MacroDropdown.vue +150 -90
  38. package/src/components/MentionDropdown.vue +154 -0
  39. package/src/components/MetricComparison.vue +89 -0
  40. package/src/components/PercentileChart.vue +67 -0
  41. package/src/components/PeriodSelector.vue +98 -0
  42. package/src/components/PermissionMatrix.vue +107 -0
  43. package/src/components/PinnedNotes.vue +129 -90
  44. package/src/components/PluginSlot.vue +68 -62
  45. package/src/components/PresenceIndicator.vue +138 -28
  46. package/src/components/PriorityBadge.stories.js +33 -0
  47. package/src/components/PriorityBadge.vue +46 -39
  48. package/src/components/QuickFilters.vue +65 -49
  49. package/src/components/ReplyComposer.vue +207 -30
  50. package/src/components/ReplyThread.vue +111 -21
  51. package/src/components/ReportChart.vue +395 -0
  52. package/src/components/ReportExportButton.vue +108 -0
  53. package/src/components/SatisfactionRating.vue +180 -128
  54. package/src/components/SaveViewDialog.vue +172 -0
  55. package/src/components/SavedViewSidebar.vue +208 -0
  56. package/src/components/ScheduleEditor.vue +104 -0
  57. package/src/components/SideConversation.vue +165 -0
  58. package/src/components/SideConversationList.vue +147 -0
  59. package/src/components/SkillTagManager.vue +79 -0
  60. package/src/components/SlaComplianceChart.vue +46 -0
  61. package/src/components/SlaTimer.stories.js +35 -0
  62. package/src/components/SlaTimer.vue +62 -49
  63. package/src/components/SnoozeButton.vue +247 -0
  64. package/src/components/StatsCard.stories.js +24 -0
  65. package/src/components/StatsCard.vue +18 -6
  66. package/src/components/StatusBadge.stories.js +53 -0
  67. package/src/components/StatusBadge.vue +78 -46
  68. package/src/components/TagSelect.vue +84 -53
  69. package/src/components/TicketFilters.vue +247 -46
  70. package/src/components/TicketLinkPanel.vue +193 -0
  71. package/src/components/TicketList.vue +536 -74
  72. package/src/components/TicketMergeDialog.vue +157 -0
  73. package/src/components/TicketSidebar.vue +167 -99
  74. package/src/components/TicketSplitDialog.vue +98 -0
  75. package/src/components/TicketTypeSelector.vue +21 -0
  76. package/src/components/TotpInput.stories.js +9 -0
  77. package/src/components/TotpInput.vue +81 -0
  78. package/src/components/TwoFactorSetup.vue +113 -0
  79. package/src/components/VariableTokenInput.vue +111 -0
  80. package/src/components/WorkflowActionConfig.vue +287 -0
  81. package/src/components/WorkflowActionList.vue +350 -0
  82. package/src/components/WorkflowConditionBuilder.vue +265 -0
  83. package/src/components/WorkflowTriggerSelector.vue +125 -0
  84. package/src/composables/useChat.js +187 -0
  85. package/src/composables/useDebouncedSearch.js +38 -0
  86. package/src/composables/useI18n.js +80 -80
  87. package/src/composables/useKeyboardShortcuts.js +46 -46
  88. package/src/composables/useMentions.js +134 -0
  89. package/src/composables/usePluginExtensions.js +159 -67
  90. package/src/composables/usePluginHooks.js +65 -0
  91. package/src/composables/useRealtime.js +182 -0
  92. package/src/defineEscalatedPlugin.js +76 -50
  93. package/src/index.js +77 -28
  94. package/src/locales/ar.json +727 -0
  95. package/src/locales/de.json +678 -430
  96. package/src/locales/en.json +717 -430
  97. package/src/locales/es.json +679 -430
  98. package/src/locales/fr.json +680 -431
  99. package/src/locales/index.js +12 -2
  100. package/src/locales/it.json +727 -0
  101. package/src/locales/ja.json +727 -0
  102. package/src/locales/ko.json +727 -0
  103. package/src/locales/nl.json +727 -0
  104. package/src/locales/pl.json +727 -0
  105. package/src/locales/pt-BR.json +727 -0
  106. package/src/locales/ru.json +727 -0
  107. package/src/locales/tr.json +727 -0
  108. package/src/locales/zh-CN.json +727 -0
  109. package/src/pages/Admin/ApiTokens/Index.vue +381 -217
  110. package/src/pages/Admin/AuditLog/Index.vue +154 -0
  111. package/src/pages/Admin/BusinessHours/Form.vue +172 -0
  112. package/src/pages/Admin/BusinessHours/Index.vue +113 -0
  113. package/src/pages/Admin/CannedResponses/Index.vue +82 -25
  114. package/src/pages/Admin/Capacity/Index.vue +134 -0
  115. package/src/pages/Admin/CustomFields/Form.vue +195 -0
  116. package/src/pages/Admin/CustomFields/Index.vue +134 -0
  117. package/src/pages/Admin/CustomObjects/Form.vue +202 -0
  118. package/src/pages/Admin/CustomObjects/Index.vue +127 -0
  119. package/src/pages/Admin/CustomObjects/Records.vue +228 -0
  120. package/src/pages/Admin/Departments/Form.vue +35 -11
  121. package/src/pages/Admin/Departments/Index.vue +74 -21
  122. package/src/pages/Admin/EscalationRules/Form.vue +86 -25
  123. package/src/pages/Admin/EscalationRules/Index.vue +158 -37
  124. package/src/pages/Admin/Import/Connect.vue +95 -0
  125. package/src/pages/Admin/Import/Index.vue +102 -0
  126. package/src/pages/Admin/Import/Mapping.vue +110 -0
  127. package/src/pages/Admin/Import/Progress.vue +153 -0
  128. package/src/pages/Admin/Import/Review.vue +76 -0
  129. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -0
  130. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -0
  131. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -0
  132. package/src/pages/Admin/Macros/Index.vue +448 -287
  133. package/src/pages/Admin/Plugins/Index.vue +327 -197
  134. package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
  135. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -0
  136. package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
  137. package/src/pages/Admin/Reports/Cohorts.vue +180 -0
  138. package/src/pages/Admin/Reports/Comparison.vue +141 -0
  139. package/src/pages/Admin/Reports/CsatReport.vue +127 -0
  140. package/src/pages/Admin/Reports/Dashboard.vue +226 -0
  141. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
  142. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
  143. package/src/pages/Admin/Reports/SlaReport.vue +162 -0
  144. package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
  145. package/src/pages/Admin/Reports.vue +206 -101
  146. package/src/pages/Admin/Roles/Form.vue +71 -0
  147. package/src/pages/Admin/Roles/Index.vue +122 -0
  148. package/src/pages/Admin/Settings/CsatSettings.vue +165 -0
  149. package/src/pages/Admin/Settings/DataRetention.vue +182 -0
  150. package/src/pages/Admin/Settings/EmailSettings.vue +243 -0
  151. package/src/pages/Admin/Settings/Sandbox.vue +135 -0
  152. package/src/pages/Admin/Settings/SsoSettings.vue +218 -0
  153. package/src/pages/Admin/Settings/TwoFactor.vue +110 -0
  154. package/src/pages/Admin/Settings.vue +847 -99
  155. package/src/pages/Admin/Skills/Form.vue +53 -0
  156. package/src/pages/Admin/Skills/Index.vue +94 -0
  157. package/src/pages/Admin/SlaPolicies/Form.vue +62 -19
  158. package/src/pages/Admin/SlaPolicies/Index.vue +85 -23
  159. package/src/pages/Admin/Statuses/Form.vue +114 -0
  160. package/src/pages/Admin/Statuses/Index.vue +153 -0
  161. package/src/pages/Admin/Tags/Index.vue +121 -35
  162. package/src/pages/Admin/Tickets/Index.vue +96 -9
  163. package/src/pages/Admin/Tickets/Show.vue +130 -37
  164. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -0
  165. package/src/pages/Admin/Webhooks/Form.vue +110 -0
  166. package/src/pages/Admin/Webhooks/Index.vue +136 -0
  167. package/src/pages/Admin/Workflows/Builder.vue +207 -0
  168. package/src/pages/Admin/Workflows/Index.vue +235 -0
  169. package/src/pages/Admin/Workflows/Logs.vue +286 -0
  170. package/src/pages/Agent/Dashboard.vue +311 -186
  171. package/src/pages/Agent/TicketIndex.vue +80 -6
  172. package/src/pages/Agent/TicketShow.vue +399 -60
  173. package/src/pages/Customer/Create.vue +37 -10
  174. package/src/pages/Customer/Index.vue +9 -2
  175. package/src/pages/Customer/KnowledgeBase/Article.vue +129 -0
  176. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -0
  177. package/src/pages/Customer/Show.vue +69 -63
  178. package/src/pages/Guest/Create.vue +40 -15
  179. package/src/pages/Guest/Show.vue +124 -93
  180. package/src/plugin.js +111 -1
  181. package/src/utils/constants.js +30 -0
  182. package/src/utils/formatting.js +35 -0
  183. package/src/utils/mentionHighlight.js +24 -0
  184. package/src/utils/sanitizeHtml.js +78 -64
  185. package/src/widget/EscalatedWidget.vue +804 -0
  186. package/src/widget/index.js +254 -0
  187. package/src/pages/Admin/Plugins.vue +0 -129
@@ -1,31 +1,157 @@
1
1
  <script setup>
2
- import { inject, computed, ref } from 'vue';
2
+ /* global localStorage */
3
+ import { inject, computed, ref, watch } from 'vue';
3
4
  import StatusBadge from './StatusBadge.vue';
4
5
  import PriorityBadge from './PriorityBadge.vue';
5
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
+ ];
6
26
 
7
27
  const props = defineProps({
8
28
  tickets: { type: Object, required: true },
9
29
  routePrefix: { type: String, default: 'escalated.customer.tickets' },
30
+ /** @deprecated Use `columns` prop instead */
10
31
  showAssignee: { type: Boolean, default: false },
32
+ columns: {
33
+ type: Array,
34
+ default: () => ['reference', 'subject', 'requester', 'status', 'priority', 'updated'],
35
+ },
11
36
  selectable: { type: Boolean, default: false },
12
37
  selectedIds: { type: Array, default: () => [] },
13
38
  assignRoute: { type: String, default: '' },
39
+ navigable: { type: Boolean, default: false },
40
+ showLiveBadge: { type: Boolean, default: false },
14
41
  });
15
42
 
16
43
  const emit = defineEmits(['update:selectedIds']);
17
- const escDark = inject('esc-dark', computed(() => false));
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 });
18
141
 
19
142
  const allSelected = computed(() => {
20
143
  if (!props.tickets.data?.length) return false;
21
- return props.tickets.data.every(t => props.selectedIds.includes(t.id));
144
+ return props.tickets.data.every((t) => props.selectedIds.includes(t.id));
22
145
  });
23
146
 
24
147
  function toggleAll() {
25
148
  if (allSelected.value) {
26
149
  emit('update:selectedIds', []);
27
150
  } else {
28
- emit('update:selectedIds', props.tickets.data.map(t => t.id));
151
+ emit(
152
+ 'update:selectedIds',
153
+ props.tickets.data.map((t) => t.id),
154
+ );
29
155
  }
30
156
  }
31
157
 
@@ -37,26 +163,31 @@ function toggleOne(id) {
37
163
  emit('update:selectedIds', ids);
38
164
  }
39
165
 
40
- function timeAgo(date) {
41
- if (!date) return '';
42
- const diff = Date.now() - new Date(date).getTime();
43
- const mins = Math.floor(diff / 60000);
44
- if (mins < 60) return `${mins}m ago`;
45
- const hrs = Math.floor(mins / 60);
46
- if (hrs < 24) return `${hrs}h ago`;
47
- const days = Math.floor(hrs / 24);
48
- return `${days}d ago`;
49
- }
50
-
51
- function slaClass(ticket) {
52
- if (ticket.sla_first_response_breached || ticket.sla_resolution_breached) return 'bg-rose-500';
166
+ function slaLabel(ticket) {
167
+ if (ticket.sla_first_response_breached || ticket.sla_resolution_breached) return 'Breached';
53
168
  if (ticket.first_response_due_at || ticket.resolution_due_at) {
54
169
  const due = ticket.resolution_due_at || ticket.first_response_due_at;
55
- const mins = (new Date(due) - Date.now()) / 60000;
56
- if (mins < 30) return 'bg-amber-500';
57
- return 'bg-emerald-500';
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`;
58
176
  }
59
- return '';
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;
60
191
  }
61
192
 
62
193
  function truncate(str, len = 60) {
@@ -65,9 +196,8 @@ function truncate(str, len = 60) {
65
196
  }
66
197
 
67
198
  const colCount = computed(() => {
68
- let count = 5; // ref, subject, status, priority, created
199
+ let count = visibleColumnDefs.value.length;
69
200
  if (props.selectable) count++;
70
- if (props.showAssignee) count++;
71
201
  return count;
72
202
  });
73
203
  </script>
@@ -78,53 +208,220 @@ const colCount = computed(() => {
78
208
  <table class="min-w-full divide-y divide-white/[0.06]">
79
209
  <thead>
80
210
  <tr class="bg-white/[0.02]">
81
- <th v-if="selectable" class="w-10 px-3 py-3">
82
- <input type="checkbox" :checked="allSelected" @change="toggleAll"
83
- class="h-4 w-4 rounded border-white/20 bg-neutral-950 text-cyan-500 focus:ring-cyan-500/20" />
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>
84
274
  </th>
85
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Reference</th>
86
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Subject</th>
87
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Requester</th>
88
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Status</th>
89
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Priority</th>
90
- <th v-if="showAssignee" class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Assignee</th>
91
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Last Reply</th>
92
275
  </tr>
93
276
  </thead>
94
277
  <tbody class="divide-y divide-white/[0.04]">
95
- <tr v-for="ticket in tickets.data" :key="ticket.id" class="group transition-colors hover:bg-white/[0.03]">
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
+ >
96
286
  <td v-if="selectable" class="w-10 px-3 py-3">
97
- <input type="checkbox" :checked="selectedIds.includes(ticket.id)" @change="toggleOne(ticket.id)"
98
- class="h-4 w-4 rounded border-white/20 bg-neutral-950 text-cyan-500 focus:ring-cyan-500/20" />
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
+ />
99
294
  </td>
100
- <td class="whitespace-nowrap px-4 py-3 text-sm font-medium">
295
+ <!-- reference -->
296
+ <td v-if="isColumnActive('reference')" class="whitespace-nowrap px-4 py-3 text-sm font-medium">
101
297
  <div class="flex items-center gap-2">
102
- <span v-if="slaClass(ticket)" :class="['h-2 w-2 shrink-0 rounded-full', slaClass(ticket)]"></span>
103
- <Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-white hover:text-neutral-300">
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
+ >
104
306
  {{ ticket.reference }}
105
307
  </Link>
106
308
  </div>
107
309
  </td>
108
- <td class="px-4 py-3 text-sm text-neutral-300">{{ truncate(ticket.subject) }}</td>
109
- <td class="px-4 py-3 text-sm text-neutral-400">
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">
110
316
  <div>{{ ticket.requester?.name || ticket.requester_name || 'Unknown' }}</div>
111
- <div class="truncate text-xs text-neutral-600" style="max-width: 150px">{{ ticket.requester?.email || ticket.requester_email || '' }}</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" />
112
356
  </td>
113
- <td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
114
- <td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
115
- <td v-if="showAssignee" class="px-4 py-3 text-sm text-neutral-500">
357
+ <!-- assignee -->
358
+ <td v-if="isColumnActive('assignee')" class="px-4 py-3 text-sm text-neutral-500">
116
359
  {{ ticket.assignee?.name || 'Unassigned' }}
117
360
  </td>
118
- <td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-600">
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">
119
367
  <template v-if="ticket.latest_reply || ticket.last_reply_at">
120
- <div class="text-neutral-400">{{ timeAgo(ticket.latest_reply?.created_at || ticket.last_reply_at) }}</div>
121
- <div class="text-xs text-neutral-600">{{ ticket.latest_reply?.author?.name || ticket.last_reply_author || '' }}</div>
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>
122
374
  </template>
123
375
  <span v-else class="text-neutral-700">—</span>
124
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>
125
422
  </tr>
126
423
  <tr v-if="!tickets.data?.length">
127
- <td :colspan="colCount" class="px-4 py-8 text-center text-sm text-neutral-600">
424
+ <td :colspan="colCount + 1" class="px-4 py-8 text-center text-sm text-neutral-600">
128
425
  No tickets found.
129
426
  </td>
130
427
  </tr>
@@ -137,53 +434,218 @@ const colCount = computed(() => {
137
434
  <table class="min-w-full divide-y divide-gray-200">
138
435
  <thead class="bg-gray-50">
139
436
  <tr>
140
- <th v-if="selectable" class="w-10 px-3 py-3">
141
- <input type="checkbox" :checked="allSelected" @change="toggleAll"
142
- class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
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>
143
500
  </th>
144
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Reference</th>
145
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Subject</th>
146
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Requester</th>
147
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Status</th>
148
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
149
- <th v-if="showAssignee" class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Assignee</th>
150
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Last Reply</th>
151
501
  </tr>
152
502
  </thead>
153
503
  <tbody class="divide-y divide-gray-200">
154
- <tr v-for="ticket in tickets.data" :key="ticket.id" class="group hover:bg-gray-50">
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
+ >
155
509
  <td v-if="selectable" class="w-10 px-3 py-3">
156
- <input type="checkbox" :checked="selectedIds.includes(ticket.id)" @change="toggleOne(ticket.id)"
157
- class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
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
+ />
158
517
  </td>
159
- <td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900">
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
+ >
160
523
  <div class="flex items-center gap-2">
161
- <span v-if="slaClass(ticket)" :class="['h-2 w-2 shrink-0 rounded-full', slaClass(ticket)]"></span>
162
- <Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-indigo-600 hover:text-indigo-900">
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
+ >
163
532
  {{ ticket.reference }}
164
533
  </Link>
165
534
  </div>
166
535
  </td>
167
- <td class="px-4 py-3 text-sm text-gray-900">{{ truncate(ticket.subject) }}</td>
168
- <td class="px-4 py-3 text-sm text-gray-500">
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">
169
542
  <div>{{ ticket.requester?.name || ticket.requester_name || 'Unknown' }}</div>
170
- <div class="truncate text-xs text-gray-400" style="max-width: 150px">{{ ticket.requester?.email || ticket.requester_email || '' }}</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" />
171
582
  </td>
172
- <td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
173
- <td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
174
- <td v-if="showAssignee" class="px-4 py-3 text-sm text-gray-500">
583
+ <!-- assignee -->
584
+ <td v-if="isColumnActive('assignee')" class="px-4 py-3 text-sm text-gray-500">
175
585
  {{ ticket.assignee?.name || 'Unassigned' }}
176
586
  </td>
177
- <td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
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">
178
593
  <template v-if="ticket.latest_reply || ticket.last_reply_at">
179
594
  <div>{{ timeAgo(ticket.latest_reply?.created_at || ticket.last_reply_at) }}</div>
180
- <div class="text-xs text-gray-400">{{ ticket.latest_reply?.author?.name || ticket.last_reply_author || '' }}</div>
595
+ <div class="text-xs text-gray-400">
596
+ {{ ticket.latest_reply?.author?.name || ticket.last_reply_author || '' }}
597
+ </div>
181
598
  </template>
182
599
  <span v-else class="text-gray-400">—</span>
183
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>
184
646
  </tr>
185
647
  <tr v-if="!tickets.data?.length">
186
- <td :colspan="colCount" class="px-4 py-8 text-center text-sm text-gray-500">
648
+ <td :colspan="colCount + 1" class="px-4 py-8 text-center text-sm text-gray-500">
187
649
  No tickets found.
188
650
  </td>
189
651
  </tr>