@escalated-dev/escalated 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/BulkActionBar.vue +97 -0
- package/src/components/EscalatedLayout.vue +220 -219
- package/src/components/FollowButton.vue +63 -0
- package/src/components/KeyboardShortcutHelp.vue +123 -0
- package/src/components/MacroDropdown.vue +90 -0
- package/src/components/PinnedNotes.vue +87 -0
- package/src/components/PresenceIndicator.vue +103 -0
- package/src/components/QuickFilters.vue +49 -0
- package/src/components/ReplyThread.vue +60 -38
- package/src/components/SatisfactionRating.vue +128 -0
- package/src/components/TicketFilters.vue +69 -58
- package/src/components/TicketList.vue +193 -94
- package/src/components/TicketSidebar.vue +99 -77
- package/src/composables/useKeyboardShortcuts.js +46 -0
- package/src/index.js +11 -0
- package/src/pages/Admin/Macros/Index.vue +287 -0
- package/src/pages/Admin/Reports.vue +101 -66
- package/src/pages/Admin/Tickets/Index.vue +39 -22
- package/src/pages/Admin/Tickets/Show.vue +145 -109
- package/src/pages/Agent/Dashboard.vue +156 -63
- package/src/pages/Agent/TicketIndex.vue +38 -21
- package/src/pages/Agent/TicketShow.vue +144 -108
- package/src/pages/Customer/Show.vue +63 -55
- package/src/pages/Guest/Show.vue +93 -86
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject } from 'vue';
|
|
3
|
+
import { router } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
action: { type: String, required: true },
|
|
7
|
+
existingRating: { type: Object, default: null },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
11
|
+
const hoveredStar = ref(0);
|
|
12
|
+
const selectedRating = ref(props.existingRating?.rating || 0);
|
|
13
|
+
const comment = ref(props.existingRating?.comment || '');
|
|
14
|
+
const processing = ref(false);
|
|
15
|
+
const submitted = ref(false);
|
|
16
|
+
|
|
17
|
+
const isReadOnly = computed(() => !!props.existingRating);
|
|
18
|
+
|
|
19
|
+
const displayRating = computed(() => {
|
|
20
|
+
if (isReadOnly.value) return props.existingRating.rating;
|
|
21
|
+
if (hoveredStar.value > 0) return hoveredStar.value;
|
|
22
|
+
return selectedRating.value;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const ratingLabels = ['', 'Terrible', 'Poor', 'Okay', 'Good', 'Excellent'];
|
|
26
|
+
|
|
27
|
+
function selectStar(star) {
|
|
28
|
+
if (isReadOnly.value) return;
|
|
29
|
+
selectedRating.value = star;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hoverStar(star) {
|
|
33
|
+
if (isReadOnly.value) return;
|
|
34
|
+
hoveredStar.value = star;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function leaveStars() {
|
|
38
|
+
hoveredStar.value = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function submit() {
|
|
42
|
+
if (!selectedRating.value || processing.value || isReadOnly.value) return;
|
|
43
|
+
processing.value = true;
|
|
44
|
+
router.post(props.action, {
|
|
45
|
+
rating: selectedRating.value,
|
|
46
|
+
comment: comment.value || null,
|
|
47
|
+
}, {
|
|
48
|
+
preserveScroll: true,
|
|
49
|
+
onSuccess: () => {
|
|
50
|
+
submitted.value = true;
|
|
51
|
+
},
|
|
52
|
+
onFinish: () => {
|
|
53
|
+
processing.value = false;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div :class="['rounded-xl border p-5',
|
|
61
|
+
escDark ? 'border-white/[0.06] bg-neutral-900/60' : 'border-gray-200 bg-white']">
|
|
62
|
+
<h3 :class="['text-sm font-semibold',
|
|
63
|
+
escDark ? 'text-neutral-200' : 'text-gray-900']">
|
|
64
|
+
{{ isReadOnly ? 'Customer Rating' : 'How was your experience?' }}
|
|
65
|
+
</h3>
|
|
66
|
+
|
|
67
|
+
<!-- Stars -->
|
|
68
|
+
<div class="mt-3 flex items-center gap-1" @mouseleave="leaveStars">
|
|
69
|
+
<button v-for="star in 5" :key="star"
|
|
70
|
+
@click="selectStar(star)"
|
|
71
|
+
@mouseenter="hoverStar(star)"
|
|
72
|
+
:disabled="isReadOnly"
|
|
73
|
+
:class="['transition-transform', !isReadOnly && 'hover:scale-110', isReadOnly && 'cursor-default']">
|
|
74
|
+
<!-- Filled star -->
|
|
75
|
+
<svg v-if="star <= displayRating" class="h-7 w-7" viewBox="0 0 24 24" fill="currentColor"
|
|
76
|
+
:class="escDark ? 'text-amber-400' : 'text-yellow-400'">
|
|
77
|
+
<path d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" />
|
|
78
|
+
</svg>
|
|
79
|
+
<!-- Empty star -->
|
|
80
|
+
<svg v-else class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"
|
|
81
|
+
:class="escDark ? 'text-neutral-600' : 'text-gray-300'">
|
|
82
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
|
83
|
+
</svg>
|
|
84
|
+
</button>
|
|
85
|
+
<span v-if="displayRating > 0"
|
|
86
|
+
:class="['ml-2 text-sm font-medium',
|
|
87
|
+
escDark ? 'text-neutral-400' : 'text-gray-500']">
|
|
88
|
+
{{ ratingLabels[displayRating] }}
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Comment -->
|
|
93
|
+
<div v-if="!isReadOnly" class="mt-4">
|
|
94
|
+
<textarea v-model="comment" rows="3"
|
|
95
|
+
placeholder="Any additional feedback? (optional)"
|
|
96
|
+
:class="['w-full rounded-lg border px-3 py-2 text-sm focus:outline-none',
|
|
97
|
+
escDark
|
|
98
|
+
? 'border-white/10 bg-neutral-950 text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:ring-1 focus:ring-white/10'
|
|
99
|
+
: 'border-gray-300 text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500']">
|
|
100
|
+
</textarea>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- Read-only comment display -->
|
|
104
|
+
<div v-if="isReadOnly && existingRating.comment" class="mt-3">
|
|
105
|
+
<p :class="['text-sm italic', escDark ? 'text-neutral-400' : 'text-gray-600']">
|
|
106
|
+
"{{ existingRating.comment }}"
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<!-- Submit -->
|
|
111
|
+
<div v-if="!isReadOnly" class="mt-3">
|
|
112
|
+
<div v-if="submitted"
|
|
113
|
+
:class="['rounded-lg px-3 py-2 text-sm font-medium',
|
|
114
|
+
escDark ? 'bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20' : 'bg-green-50 text-green-700']">
|
|
115
|
+
Thank you for your feedback!
|
|
116
|
+
</div>
|
|
117
|
+
<button v-else @click="submit"
|
|
118
|
+
:disabled="!selectedRating || processing"
|
|
119
|
+
:class="['rounded-lg px-4 py-2 text-sm font-medium text-white transition-all',
|
|
120
|
+
escDark
|
|
121
|
+
? 'bg-gradient-to-r from-cyan-500 to-violet-500 hover:from-cyan-400 hover:to-violet-400'
|
|
122
|
+
: 'bg-blue-600 hover:bg-blue-700',
|
|
123
|
+
(!selectedRating || processing) && 'cursor-not-allowed opacity-40']">
|
|
124
|
+
Submit Rating
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|
|
@@ -1,58 +1,69 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { inject, computed, reactive, watch } from 'vue';
|
|
3
|
-
|
|
4
|
-
const props = defineProps({
|
|
5
|
-
statuses: { type: Array, default: () => ['open', 'in_progress', 'waiting_on_customer', 'waiting_on_agent', 'escalated', 'resolved', 'closed'] },
|
|
6
|
-
priorities: { type: Array, default: () => ['low', 'medium', 'high', 'urgent', 'critical'] },
|
|
7
|
-
agents: { type: Array, default: () => [] },
|
|
8
|
-
departments: { type: Array, default: () => [] },
|
|
9
|
-
modelValue: { type: Object, default: () => ({}) },
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<option value="">
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<option value="">
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
</
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, computed, reactive, watch } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
statuses: { type: Array, default: () => ['open', 'in_progress', 'waiting_on_customer', 'waiting_on_agent', 'escalated', 'resolved', 'closed'] },
|
|
6
|
+
priorities: { type: Array, default: () => ['low', 'medium', 'high', 'urgent', 'critical'] },
|
|
7
|
+
agents: { type: Array, default: () => [] },
|
|
8
|
+
departments: { type: Array, default: () => [] },
|
|
9
|
+
modelValue: { type: Object, default: () => ({}) },
|
|
10
|
+
showFollowing: { type: Boolean, default: false },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits(['update:modelValue']);
|
|
14
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
15
|
+
|
|
16
|
+
const filters = reactive({
|
|
17
|
+
status: props.modelValue.status || '',
|
|
18
|
+
priority: props.modelValue.priority || '',
|
|
19
|
+
assigned_to: props.modelValue.assigned_to || '',
|
|
20
|
+
department_id: props.modelValue.department_id || '',
|
|
21
|
+
search: props.modelValue.search || '',
|
|
22
|
+
following: props.modelValue.following || '',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
watch(filters, (val) => {
|
|
26
|
+
emit('update:modelValue', { ...val });
|
|
27
|
+
}, { deep: true });
|
|
28
|
+
|
|
29
|
+
const inputClass = computed(() => escDark.value
|
|
30
|
+
? 'rounded-lg border border-white/10 bg-gray-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10'
|
|
31
|
+
: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none'
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const selectClass = computed(() => escDark.value
|
|
35
|
+
? 'rounded-lg border border-white/10 bg-gray-900 px-2 py-1.5 text-sm text-gray-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10'
|
|
36
|
+
: 'rounded-md border border-gray-300 px-2 py-1.5 text-sm'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const checkboxClass = computed(() => escDark.value
|
|
40
|
+
? 'h-4 w-4 rounded border-white/20 bg-neutral-950 text-cyan-500 focus:ring-cyan-500/20'
|
|
41
|
+
: 'h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500'
|
|
42
|
+
);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
47
|
+
<input v-model="filters.search" type="text" placeholder="Search tickets..." :class="inputClass" />
|
|
48
|
+
<select v-model="filters.status" :class="selectClass">
|
|
49
|
+
<option value="">All Statuses</option>
|
|
50
|
+
<option v-for="s in statuses" :key="s" :value="s">{{ s.replace(/_/g, ' ') }}</option>
|
|
51
|
+
</select>
|
|
52
|
+
<select v-model="filters.priority" :class="selectClass">
|
|
53
|
+
<option value="">All Priorities</option>
|
|
54
|
+
<option v-for="p in priorities" :key="p" :value="p">{{ p }}</option>
|
|
55
|
+
</select>
|
|
56
|
+
<select v-if="agents.length" v-model="filters.assigned_to" :class="selectClass">
|
|
57
|
+
<option value="">All Agents</option>
|
|
58
|
+
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
|
59
|
+
</select>
|
|
60
|
+
<select v-if="departments.length" v-model="filters.department_id" :class="selectClass">
|
|
61
|
+
<option value="">All Departments</option>
|
|
62
|
+
<option v-for="d in departments" :key="d.id" :value="d.id">{{ d.name }}</option>
|
|
63
|
+
</select>
|
|
64
|
+
<label v-if="showFollowing" class="flex items-center gap-2">
|
|
65
|
+
<input v-model="filters.following" type="checkbox" true-value="1" false-value="" :class="checkboxClass" />
|
|
66
|
+
<span :class="['text-sm', escDark ? 'text-neutral-400' : 'text-gray-600']">Following</span>
|
|
67
|
+
</label>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
@@ -1,94 +1,193 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { inject, computed } from 'vue';
|
|
3
|
-
import StatusBadge from './StatusBadge.vue';
|
|
4
|
-
import PriorityBadge from './PriorityBadge.vue';
|
|
5
|
-
import { Link } from '@inertiajs/vue3';
|
|
6
|
-
|
|
7
|
-
defineProps({
|
|
8
|
-
tickets: { type: Object, required: true },
|
|
9
|
-
routePrefix: { type: String, default: 'escalated.customer.tickets' },
|
|
10
|
-
showAssignee: { type: Boolean, default: false },
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
</
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
</
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, computed, ref } from 'vue';
|
|
3
|
+
import StatusBadge from './StatusBadge.vue';
|
|
4
|
+
import PriorityBadge from './PriorityBadge.vue';
|
|
5
|
+
import { Link, router } from '@inertiajs/vue3';
|
|
6
|
+
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
tickets: { type: Object, required: true },
|
|
9
|
+
routePrefix: { type: String, default: 'escalated.customer.tickets' },
|
|
10
|
+
showAssignee: { type: Boolean, default: false },
|
|
11
|
+
selectable: { type: Boolean, default: false },
|
|
12
|
+
selectedIds: { type: Array, default: () => [] },
|
|
13
|
+
assignRoute: { type: String, default: '' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits(['update:selectedIds']);
|
|
17
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
18
|
+
|
|
19
|
+
const allSelected = computed(() => {
|
|
20
|
+
if (!props.tickets.data?.length) return false;
|
|
21
|
+
return props.tickets.data.every(t => props.selectedIds.includes(t.id));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function toggleAll() {
|
|
25
|
+
if (allSelected.value) {
|
|
26
|
+
emit('update:selectedIds', []);
|
|
27
|
+
} else {
|
|
28
|
+
emit('update:selectedIds', props.tickets.data.map(t => t.id));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toggleOne(id) {
|
|
33
|
+
const ids = [...props.selectedIds];
|
|
34
|
+
const idx = ids.indexOf(id);
|
|
35
|
+
if (idx >= 0) ids.splice(idx, 1);
|
|
36
|
+
else ids.push(id);
|
|
37
|
+
emit('update:selectedIds', ids);
|
|
38
|
+
}
|
|
39
|
+
|
|
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';
|
|
53
|
+
if (ticket.first_response_due_at || ticket.resolution_due_at) {
|
|
54
|
+
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';
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function truncate(str, len = 60) {
|
|
63
|
+
if (!str) return '';
|
|
64
|
+
return str.length > len ? str.slice(0, len) + '...' : str;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const colCount = computed(() => {
|
|
68
|
+
let count = 5; // ref, subject, status, priority, created
|
|
69
|
+
if (props.selectable) count++;
|
|
70
|
+
if (props.showAssignee) count++;
|
|
71
|
+
return count;
|
|
72
|
+
});
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<!-- Dark mode -->
|
|
77
|
+
<div v-if="escDark" class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
|
|
78
|
+
<table class="min-w-full divide-y divide-white/[0.06]">
|
|
79
|
+
<thead>
|
|
80
|
+
<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" />
|
|
84
|
+
</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
|
+
</tr>
|
|
93
|
+
</thead>
|
|
94
|
+
<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]">
|
|
96
|
+
<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" />
|
|
99
|
+
</td>
|
|
100
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium">
|
|
101
|
+
<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">
|
|
104
|
+
{{ ticket.reference }}
|
|
105
|
+
</Link>
|
|
106
|
+
</div>
|
|
107
|
+
</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">
|
|
110
|
+
<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>
|
|
112
|
+
</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">
|
|
116
|
+
{{ ticket.assignee?.name || 'Unassigned' }}
|
|
117
|
+
</td>
|
|
118
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-600">
|
|
119
|
+
<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>
|
|
122
|
+
</template>
|
|
123
|
+
<span v-else class="text-neutral-700">—</span>
|
|
124
|
+
</td>
|
|
125
|
+
</tr>
|
|
126
|
+
<tr v-if="!tickets.data?.length">
|
|
127
|
+
<td :colspan="colCount" class="px-4 py-8 text-center text-sm text-neutral-600">
|
|
128
|
+
No tickets found.
|
|
129
|
+
</td>
|
|
130
|
+
</tr>
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- Light mode -->
|
|
136
|
+
<div v-else class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
|
137
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
138
|
+
<thead class="bg-gray-50">
|
|
139
|
+
<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" />
|
|
143
|
+
</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
|
+
</tr>
|
|
152
|
+
</thead>
|
|
153
|
+
<tbody class="divide-y divide-gray-200">
|
|
154
|
+
<tr v-for="ticket in tickets.data" :key="ticket.id" class="group hover:bg-gray-50">
|
|
155
|
+
<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" />
|
|
158
|
+
</td>
|
|
159
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900">
|
|
160
|
+
<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">
|
|
163
|
+
{{ ticket.reference }}
|
|
164
|
+
</Link>
|
|
165
|
+
</div>
|
|
166
|
+
</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">
|
|
169
|
+
<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>
|
|
171
|
+
</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">
|
|
175
|
+
{{ ticket.assignee?.name || 'Unassigned' }}
|
|
176
|
+
</td>
|
|
177
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
|
178
|
+
<template v-if="ticket.latest_reply || ticket.last_reply_at">
|
|
179
|
+
<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>
|
|
181
|
+
</template>
|
|
182
|
+
<span v-else class="text-gray-400">—</span>
|
|
183
|
+
</td>
|
|
184
|
+
</tr>
|
|
185
|
+
<tr v-if="!tickets.data?.length">
|
|
186
|
+
<td :colspan="colCount" class="px-4 py-8 text-center text-sm text-gray-500">
|
|
187
|
+
No tickets found.
|
|
188
|
+
</td>
|
|
189
|
+
</tr>
|
|
190
|
+
</tbody>
|
|
191
|
+
</table>
|
|
192
|
+
</div>
|
|
193
|
+
</template>
|