@escalated-dev/escalated 0.8.0 → 0.11.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.
- package/LICENSE +21 -21
- package/README.md +388 -361
- package/package.json +85 -84
- package/src/components/ActiveChatsPanel.vue +246 -246
- package/src/components/AdminDashboard.stories.js +1423 -1423
- package/src/components/AttachmentList.vue +65 -65
- package/src/components/ChatActionBar.vue +226 -226
- package/src/components/ChatBubble.vue +162 -162
- package/src/components/ChatComposer.vue +308 -308
- package/src/components/ChatQueue.vue +222 -222
- package/src/components/ChatThread.vue +197 -197
- package/src/components/CustomTicketActions.stories.js +71 -0
- package/src/components/CustomTicketActions.vue +101 -0
- package/src/components/EscalatedLayout.vue +628 -604
- package/src/components/FollowButton.vue +20 -1
- package/src/components/MentionDropdown.vue +154 -154
- package/src/components/MetricComparison.vue +89 -89
- package/src/components/PercentileChart.vue +67 -67
- package/src/components/PeriodSelector.vue +98 -98
- package/src/components/PresenceIndicator.vue +228 -213
- package/src/components/ReplyComposer.vue +336 -336
- package/src/components/ReplyThread.vue +153 -153
- package/src/components/ReportChart.vue +395 -395
- package/src/components/ReportExportButton.vue +108 -108
- package/src/components/SaveViewDialog.vue +172 -172
- package/src/components/SavedViewSidebar.vue +208 -208
- package/src/components/SkillTagManager.vue +3 -0
- package/src/components/SnoozeButton.vue +247 -247
- package/src/components/TicketFilters.vue +270 -270
- package/src/components/TicketList.vue +655 -655
- package/src/components/TicketSidebar.vue +4 -0
- package/src/components/TicketSplitDialog.vue +98 -98
- package/src/components/TicketSubjects.stories.js +63 -0
- package/src/components/TicketSubjects.vue +107 -0
- package/src/components/TwoFactorSetup.vue +1 -37
- package/src/components/VariableTokenInput.vue +111 -111
- package/src/components/WorkflowActionConfig.vue +287 -287
- package/src/components/WorkflowActionList.vue +350 -350
- package/src/components/WorkflowConditionBuilder.vue +265 -265
- package/src/components/WorkflowTriggerSelector.vue +125 -125
- package/src/components/admin/newsletters/AnalyticsTiles.stories.js +14 -0
- package/src/components/admin/newsletters/AnalyticsTiles.vue +55 -0
- package/src/components/admin/newsletters/DeliveriesTable.stories.js +28 -0
- package/src/components/admin/newsletters/DeliveriesTable.vue +104 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.stories.js +11 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.vue +53 -0
- package/src/components/admin/newsletters/ListMemberTable.stories.js +12 -0
- package/src/components/admin/newsletters/ListMemberTable.vue +53 -0
- package/src/components/admin/newsletters/MarkdownEditor.stories.js +6 -0
- package/src/components/admin/newsletters/MarkdownEditor.vue +53 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.stories.js +6 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.vue +84 -0
- package/src/components/admin/newsletters/PreviewIframe.stories.js +7 -0
- package/src/components/admin/newsletters/PreviewIframe.vue +40 -0
- package/src/components/admin/newsletters/SummaryTiles.stories.js +8 -0
- package/src/components/admin/newsletters/SummaryTiles.vue +63 -0
- package/src/composables/useChat.js +187 -187
- package/src/composables/useI18n.js +102 -102
- package/src/composables/useMentions.js +134 -134
- package/src/composables/usePermissions.js +31 -0
- package/src/composables/useRealtime.js +182 -182
- package/src/index.js +85 -84
- package/src/locales/README.md +13 -13
- package/src/locales/ar.json +727 -727
- package/src/locales/de.json +690 -690
- package/src/locales/en.json +889 -729
- package/src/locales/es.json +691 -691
- package/src/locales/fr.json +691 -691
- package/src/locales/index.js +16 -16
- package/src/locales/it.json +727 -727
- package/src/locales/ja.json +727 -727
- package/src/locales/ko.json +727 -727
- package/src/locales/nl.json +727 -727
- package/src/locales/pl.json +727 -727
- package/src/locales/pt-BR.json +727 -727
- package/src/locales/ru.json +727 -727
- package/src/locales/tr.json +727 -727
- package/src/locales/zh-CN.json +727 -727
- package/src/pages/Admin/Automations/Form.vue +280 -0
- package/src/pages/Admin/Automations/Index.vue +230 -0
- package/src/pages/Admin/Newsletters/Compose.vue +192 -0
- package/src/pages/Admin/Newsletters/Edit.vue +24 -0
- package/src/pages/Admin/Newsletters/Index.vue +141 -0
- package/src/pages/Admin/Newsletters/Lists/Create.vue +64 -0
- package/src/pages/Admin/Newsletters/Lists/Index.vue +59 -0
- package/src/pages/Admin/Newsletters/Lists/Show.vue +53 -0
- package/src/pages/Admin/Newsletters/Settings.vue +85 -0
- package/src/pages/Admin/Newsletters/Show.vue +140 -0
- package/src/pages/Admin/Newsletters/Templates/Create.vue +11 -0
- package/src/pages/Admin/Newsletters/Templates/Index.vue +54 -0
- package/src/pages/Admin/Newsletters/Templates/Show.vue +65 -0
- package/src/pages/Admin/Newsletters/usePreview.js +44 -0
- package/src/pages/Admin/Plugins/Index.vue +10 -2
- package/src/pages/Admin/Reports/AgentDetail.vue +132 -132
- package/src/pages/Admin/Reports/AgentRanking.vue +246 -246
- package/src/pages/Admin/Reports/Cohorts.vue +180 -180
- package/src/pages/Admin/Reports/Comparison.vue +141 -141
- package/src/pages/Admin/Reports/Dashboard.vue +226 -226
- package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -164
- package/src/pages/Admin/Reports/ResponseTimes.vue +164 -164
- package/src/pages/Admin/Reports/SlaTrends.vue +183 -183
- package/src/pages/Admin/Settings/DatabaseConnection.vue +228 -0
- package/src/pages/Admin/Settings/PublicTickets.vue +163 -0
- package/src/pages/Admin/Settings/SsoSettings.vue +157 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +32 -6
- package/src/pages/Admin/Settings.vue +1008 -1008
- package/src/pages/Admin/Skills/Form.vue +30 -10
- package/src/pages/Admin/Tickets/Index.vue +126 -126
- package/src/pages/Admin/Tickets/Show.vue +254 -249
- package/src/pages/Admin/Users/Index.vue +145 -145
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +1 -2
- package/src/pages/Admin/Workflows/Builder.vue +207 -207
- package/src/pages/Admin/Workflows/Form.vue +24 -0
- package/src/pages/Admin/Workflows/Index.vue +235 -235
- package/src/pages/Admin/Workflows/Logs.vue +286 -286
- package/src/pages/Agent/TicketIndex.vue +112 -112
- package/src/pages/Agent/TicketShow.vue +547 -494
- package/src/pages/Customer/Index.vue +56 -56
- package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
- package/src/pages/Plugin/Page.vue +61 -0
- package/src/plugin.js +175 -175
- package/src/utils/mentionHighlight.js +24 -24
- package/src/widget/EscalatedWidget.vue +804 -804
- 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>
|