@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.
@@ -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
- const emit = defineEmits(['update:modelValue']);
13
- const escDark = inject('esc-dark', computed(() => false));
14
-
15
- const filters = reactive({
16
- status: props.modelValue.status || '',
17
- priority: props.modelValue.priority || '',
18
- assigned_to: props.modelValue.assigned_to || '',
19
- department_id: props.modelValue.department_id || '',
20
- search: props.modelValue.search || '',
21
- });
22
-
23
- watch(filters, (val) => {
24
- emit('update:modelValue', { ...val });
25
- }, { deep: true });
26
-
27
- const inputClass = computed(() => escDark.value
28
- ? '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'
29
- : 'rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none'
30
- );
31
-
32
- const selectClass = computed(() => escDark.value
33
- ? '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'
34
- : 'rounded-md border border-gray-300 px-2 py-1.5 text-sm'
35
- );
36
- </script>
37
-
38
- <template>
39
- <div class="flex flex-wrap items-center gap-3">
40
- <input v-model="filters.search" type="text" placeholder="Search tickets..." :class="inputClass" />
41
- <select v-model="filters.status" :class="selectClass">
42
- <option value="">All Statuses</option>
43
- <option v-for="s in statuses" :key="s" :value="s">{{ s.replace(/_/g, ' ') }}</option>
44
- </select>
45
- <select v-model="filters.priority" :class="selectClass">
46
- <option value="">All Priorities</option>
47
- <option v-for="p in priorities" :key="p" :value="p">{{ p }}</option>
48
- </select>
49
- <select v-if="agents.length" v-model="filters.assigned_to" :class="selectClass">
50
- <option value="">All Agents</option>
51
- <option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
52
- </select>
53
- <select v-if="departments.length" v-model="filters.department_id" :class="selectClass">
54
- <option value="">All Departments</option>
55
- <option v-for="d in departments" :key="d.id" :value="d.id">{{ d.name }}</option>
56
- </select>
57
- </div>
58
- </template>
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
- const escDark = inject('esc-dark', computed(() => false));
14
- </script>
15
-
16
- <template>
17
- <!-- Dark mode -->
18
- <div v-if="escDark" class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
19
- <table class="min-w-full divide-y divide-white/[0.06]">
20
- <thead>
21
- <tr class="bg-white/[0.02]">
22
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Reference</th>
23
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Subject</th>
24
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Status</th>
25
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Priority</th>
26
- <th v-if="showAssignee" class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Assignee</th>
27
- <th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Created</th>
28
- </tr>
29
- </thead>
30
- <tbody class="divide-y divide-white/[0.04]">
31
- <tr v-for="ticket in tickets.data" :key="ticket.id" class="transition-colors hover:bg-white/[0.03]">
32
- <td class="whitespace-nowrap px-4 py-3 text-sm font-medium">
33
- <Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-white hover:text-neutral-300">
34
- {{ ticket.reference }}
35
- </Link>
36
- </td>
37
- <td class="px-4 py-3 text-sm text-neutral-300">{{ ticket.subject }}</td>
38
- <td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
39
- <td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
40
- <td v-if="showAssignee" class="px-4 py-3 text-sm text-neutral-500">
41
- {{ ticket.assignee?.name || 'Unassigned' }}
42
- </td>
43
- <td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-600">
44
- {{ new Date(ticket.created_at).toLocaleDateString() }}
45
- </td>
46
- </tr>
47
- <tr v-if="!tickets.data?.length">
48
- <td :colspan="showAssignee ? 6 : 5" class="px-4 py-8 text-center text-sm text-neutral-600">
49
- No tickets found.
50
- </td>
51
- </tr>
52
- </tbody>
53
- </table>
54
- </div>
55
-
56
- <!-- Light mode -->
57
- <div v-else class="overflow-hidden rounded-lg border border-gray-200 bg-white">
58
- <table class="min-w-full divide-y divide-gray-200">
59
- <thead class="bg-gray-50">
60
- <tr>
61
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Reference</th>
62
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Subject</th>
63
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Status</th>
64
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
65
- <th v-if="showAssignee" class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Assignee</th>
66
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Created</th>
67
- </tr>
68
- </thead>
69
- <tbody class="divide-y divide-gray-200">
70
- <tr v-for="ticket in tickets.data" :key="ticket.id" class="hover:bg-gray-50">
71
- <td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900">
72
- <Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-indigo-600 hover:text-indigo-900">
73
- {{ ticket.reference }}
74
- </Link>
75
- </td>
76
- <td class="px-4 py-3 text-sm text-gray-900">{{ ticket.subject }}</td>
77
- <td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
78
- <td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
79
- <td v-if="showAssignee" class="px-4 py-3 text-sm text-gray-500">
80
- {{ ticket.assignee?.name || 'Unassigned' }}
81
- </td>
82
- <td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
83
- {{ new Date(ticket.created_at).toLocaleDateString() }}
84
- </td>
85
- </tr>
86
- <tr v-if="!tickets.data?.length">
87
- <td :colspan="showAssignee ? 6 : 5" class="px-4 py-8 text-center text-sm text-gray-500">
88
- No tickets found.
89
- </td>
90
- </tr>
91
- </tbody>
92
- </table>
93
- </div>
94
- </template>
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>