@escalated-dev/escalated 0.2.1 → 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/ActivityTimeline.vue +8 -4
- package/src/components/AssigneeSelect.vue +6 -2
- package/src/components/AttachmentList.vue +9 -3
- package/src/components/BulkActionBar.vue +97 -0
- package/src/components/EscalatedLayout.vue +101 -94
- package/src/components/FileDropzone.vue +9 -4
- 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/PriorityBadge.vue +20 -2
- package/src/components/QuickFilters.vue +49 -0
- package/src/components/ReplyComposer.vue +52 -3
- package/src/components/ReplyThread.vue +60 -30
- package/src/components/SatisfactionRating.vue +128 -0
- package/src/components/SlaTimer.vue +12 -6
- package/src/components/StatsCard.vue +26 -6
- package/src/components/StatusBadge.vue +24 -2
- package/src/components/TagSelect.vue +8 -4
- package/src/components/TicketFilters.vue +69 -52
- package/src/components/TicketList.vue +193 -51
- package/src/components/TicketSidebar.vue +99 -70
- package/src/composables/useKeyboardShortcuts.js +46 -0
- package/src/index.js +11 -0
- package/src/pages/Admin/CannedResponses/Index.vue +29 -17
- package/src/pages/Admin/Departments/Form.vue +12 -11
- package/src/pages/Admin/Departments/Index.vue +26 -18
- package/src/pages/Admin/EscalationRules/Form.vue +21 -20
- package/src/pages/Admin/EscalationRules/Index.vue +26 -18
- package/src/pages/Admin/Macros/Index.vue +287 -0
- package/src/pages/Admin/Reports.vue +101 -52
- package/src/pages/Admin/Settings.vue +260 -0
- package/src/pages/Admin/SlaPolicies/Form.vue +21 -20
- package/src/pages/Admin/SlaPolicies/Index.vue +28 -20
- package/src/pages/Admin/Tags/Index.vue +48 -23
- package/src/pages/Admin/Tickets/Index.vue +39 -0
- package/src/pages/Admin/Tickets/Show.vue +145 -0
- package/src/pages/Agent/Dashboard.vue +156 -51
- 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/Create.vue +97 -0
- package/src/pages/Guest/Show.vue +93 -0
|
@@ -1,51 +1,193 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
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
|
-
|
|
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>
|
|
@@ -1,70 +1,99 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
<div v-if="
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
3
|
+
import StatusBadge from './StatusBadge.vue';
|
|
4
|
+
import PriorityBadge from './PriorityBadge.vue';
|
|
5
|
+
import SlaTimer from './SlaTimer.vue';
|
|
6
|
+
import AssigneeSelect from './AssigneeSelect.vue';
|
|
7
|
+
import TagSelect from './TagSelect.vue';
|
|
8
|
+
import ActivityTimeline from './ActivityTimeline.vue';
|
|
9
|
+
|
|
10
|
+
defineProps({
|
|
11
|
+
ticket: { type: Object, required: true },
|
|
12
|
+
agents: { type: Array, default: () => [] },
|
|
13
|
+
tags: { type: Array, default: () => [] },
|
|
14
|
+
activities: { type: Array, default: () => [] },
|
|
15
|
+
editable: { type: Boolean, default: false },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status']);
|
|
19
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
20
|
+
|
|
21
|
+
const cardClass = computed(() => escDark.value
|
|
22
|
+
? 'rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4'
|
|
23
|
+
: 'rounded-lg border border-gray-200 bg-white p-4'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function renderStars(rating) {
|
|
27
|
+
return Array.from({ length: 5 }, (_, i) => i < rating ? 'filled' : 'empty');
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<aside class="space-y-4">
|
|
33
|
+
<div :class="cardClass">
|
|
34
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Details</h3>
|
|
35
|
+
<dl class="space-y-2 text-sm">
|
|
36
|
+
<div class="flex justify-between">
|
|
37
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Status</dt>
|
|
38
|
+
<dd><StatusBadge :status="ticket.status" /></dd>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="flex justify-between">
|
|
41
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Priority</dt>
|
|
42
|
+
<dd><PriorityBadge :priority="ticket.priority" /></dd>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="flex justify-between">
|
|
45
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Reference</dt>
|
|
46
|
+
<dd :class="['font-mono text-xs', escDark ? 'text-white' : '']">{{ ticket.reference }}</dd>
|
|
47
|
+
</div>
|
|
48
|
+
<div v-if="ticket.department" class="flex justify-between">
|
|
49
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Department</dt>
|
|
50
|
+
<dd :class="escDark ? 'text-neutral-300' : ''">{{ ticket.department.name }}</dd>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="flex justify-between">
|
|
53
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Created</dt>
|
|
54
|
+
<dd :class="escDark ? 'text-neutral-300' : ''">{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
|
|
55
|
+
</div>
|
|
56
|
+
</dl>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Satisfaction Rating (read-only) -->
|
|
60
|
+
<div v-if="ticket.satisfaction_rating" :class="cardClass">
|
|
61
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Customer Satisfaction</h3>
|
|
62
|
+
<div class="flex items-center gap-1">
|
|
63
|
+
<svg v-for="(star, i) in renderStars(ticket.satisfaction_rating.rating)" :key="i"
|
|
64
|
+
class="h-5 w-5" :class="star === 'filled' ? 'text-amber-400' : (escDark ? 'text-neutral-700' : 'text-gray-300')"
|
|
65
|
+
fill="currentColor" viewBox="0 0 20 20">
|
|
66
|
+
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
67
|
+
</svg>
|
|
68
|
+
<span :class="['ml-2 text-sm font-medium', escDark ? 'text-neutral-300' : 'text-gray-700']">
|
|
69
|
+
{{ ticket.satisfaction_rating.rating }}/5
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
<p v-if="ticket.satisfaction_rating.comment" :class="['mt-2 text-xs italic', escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
73
|
+
"{{ ticket.satisfaction_rating.comment }}"
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div v-if="ticket.first_response_due_at || ticket.resolution_due_at" class="space-y-2">
|
|
78
|
+
<SlaTimer v-if="ticket.first_response_due_at" :due-at="ticket.first_response_due_at"
|
|
79
|
+
:breached="ticket.sla_first_response_breached" label="First Response" />
|
|
80
|
+
<SlaTimer v-if="ticket.resolution_due_at" :due-at="ticket.resolution_due_at"
|
|
81
|
+
:breached="ticket.sla_resolution_breached" label="Resolution" />
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div v-if="editable && agents.length" :class="cardClass">
|
|
85
|
+
<AssigneeSelect :agents="agents" :model-value="ticket.assigned_to"
|
|
86
|
+
@update:model-value="emit('assign', $event)" />
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div v-if="editable && tags.length" :class="cardClass">
|
|
90
|
+
<TagSelect :tags="tags" :model-value="(ticket.tags || []).map(t => t.id)"
|
|
91
|
+
@update:model-value="emit('tags', $event)" />
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div v-if="activities.length" :class="cardClass">
|
|
95
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Activity</h3>
|
|
96
|
+
<ActivityTimeline :activities="activities" />
|
|
97
|
+
</div>
|
|
98
|
+
</aside>
|
|
99
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { onMounted, onUnmounted, unref } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Composable for registering keyboard shortcuts.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} shortcuts - Map of key to handler function, e.g. { 'r': () => {}, 'n': () => {} }
|
|
7
|
+
* @param {Object} options - Optional configuration
|
|
8
|
+
* @param {import('vue').Ref<boolean>} options.enabled - Ref controlling whether shortcuts are active (default: true)
|
|
9
|
+
*/
|
|
10
|
+
export function useKeyboardShortcuts(shortcuts, options = {}) {
|
|
11
|
+
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
12
|
+
|
|
13
|
+
function handleKeydown(event) {
|
|
14
|
+
// Check if shortcuts are enabled
|
|
15
|
+
if (options.enabled !== undefined && !unref(options.enabled)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Skip when focus is inside form elements or contenteditable
|
|
20
|
+
const target = event.target;
|
|
21
|
+
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Skip if modifier keys are held (allow shift for ? and similar)
|
|
26
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const key = event.key;
|
|
31
|
+
const handler = shortcuts[key];
|
|
32
|
+
|
|
33
|
+
if (handler && typeof handler === 'function') {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
handler(event);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onMounted(() => {
|
|
40
|
+
document.addEventListener('keydown', handleKeydown);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
onUnmounted(() => {
|
|
44
|
+
document.removeEventListener('keydown', handleKeydown);
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,11 +5,19 @@ export { EscalatedPlugin } from './plugin'
|
|
|
5
5
|
export { default as ActivityTimeline } from './components/ActivityTimeline.vue'
|
|
6
6
|
export { default as AssigneeSelect } from './components/AssigneeSelect.vue'
|
|
7
7
|
export { default as AttachmentList } from './components/AttachmentList.vue'
|
|
8
|
+
export { default as BulkActionBar } from './components/BulkActionBar.vue'
|
|
8
9
|
export { default as EscalatedLayout } from './components/EscalatedLayout.vue'
|
|
9
10
|
export { default as FileDropzone } from './components/FileDropzone.vue'
|
|
11
|
+
export { default as FollowButton } from './components/FollowButton.vue'
|
|
12
|
+
export { default as KeyboardShortcutHelp } from './components/KeyboardShortcutHelp.vue'
|
|
13
|
+
export { default as MacroDropdown } from './components/MacroDropdown.vue'
|
|
14
|
+
export { default as PinnedNotes } from './components/PinnedNotes.vue'
|
|
15
|
+
export { default as PresenceIndicator } from './components/PresenceIndicator.vue'
|
|
10
16
|
export { default as PriorityBadge } from './components/PriorityBadge.vue'
|
|
17
|
+
export { default as QuickFilters } from './components/QuickFilters.vue'
|
|
11
18
|
export { default as ReplyComposer } from './components/ReplyComposer.vue'
|
|
12
19
|
export { default as ReplyThread } from './components/ReplyThread.vue'
|
|
20
|
+
export { default as SatisfactionRating } from './components/SatisfactionRating.vue'
|
|
13
21
|
export { default as SlaTimer } from './components/SlaTimer.vue'
|
|
14
22
|
export { default as StatsCard } from './components/StatsCard.vue'
|
|
15
23
|
export { default as StatusBadge } from './components/StatusBadge.vue'
|
|
@@ -17,3 +25,6 @@ export { default as TagSelect } from './components/TagSelect.vue'
|
|
|
17
25
|
export { default as TicketFilters } from './components/TicketFilters.vue'
|
|
18
26
|
export { default as TicketList } from './components/TicketList.vue'
|
|
19
27
|
export { default as TicketSidebar } from './components/TicketSidebar.vue'
|
|
28
|
+
|
|
29
|
+
// Composables
|
|
30
|
+
export { useKeyboardShortcuts } from './composables/useKeyboardShortcuts'
|
|
@@ -24,34 +24,46 @@ function destroy(id) {
|
|
|
24
24
|
<template>
|
|
25
25
|
<EscalatedLayout title="Canned Responses">
|
|
26
26
|
<div class="mb-4 flex justify-end">
|
|
27
|
-
<button @click="showForm = !showForm"
|
|
27
|
+
<button @click="showForm = !showForm"
|
|
28
|
+
:class="showForm
|
|
29
|
+
? 'rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-medium text-neutral-300 transition-colors hover:bg-white/[0.06]'
|
|
30
|
+
: 'rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-4 py-2 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400'">
|
|
28
31
|
{{ showForm ? 'Cancel' : 'Add Response' }}
|
|
29
32
|
</button>
|
|
30
33
|
</div>
|
|
31
|
-
<form v-if="showForm" @submit.prevent="create" class="mb-6 space-y-3 rounded-
|
|
32
|
-
<input v-model="form.title" type="text" placeholder="Title" required
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
34
|
+
<form v-if="showForm" @submit.prevent="create" class="mb-6 space-y-3 rounded-xl border border-white/[0.06] bg-neutral-900/60 p-5">
|
|
35
|
+
<input v-model="form.title" type="text" placeholder="Title" required
|
|
36
|
+
class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10" />
|
|
37
|
+
<textarea v-model="form.body" rows="4" placeholder="Response body..." required
|
|
38
|
+
class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"></textarea>
|
|
39
|
+
<div class="flex items-center gap-3">
|
|
40
|
+
<input v-model="form.category" type="text" placeholder="Category (optional)"
|
|
41
|
+
class="rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10" />
|
|
36
42
|
<label class="flex items-center gap-2">
|
|
37
|
-
<input v-model="form.is_shared" type="checkbox" class="rounded border-
|
|
38
|
-
<span class="text-sm text-
|
|
43
|
+
<input v-model="form.is_shared" type="checkbox" class="rounded border-white/20 bg-neutral-900 text-white focus:ring-white/10" />
|
|
44
|
+
<span class="text-sm text-neutral-300">Shared</span>
|
|
39
45
|
</label>
|
|
40
|
-
<button type="submit" :disabled="form.processing"
|
|
46
|
+
<button type="submit" :disabled="form.processing"
|
|
47
|
+
class="ml-auto rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-4 py-2 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400 disabled:opacity-50">
|
|
48
|
+
Create
|
|
49
|
+
</button>
|
|
41
50
|
</div>
|
|
42
51
|
</form>
|
|
43
52
|
<div class="space-y-3">
|
|
44
|
-
<div v-
|
|
53
|
+
<div v-if="!responses?.length" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 px-4 py-12 text-center">
|
|
54
|
+
<svg class="mx-auto mb-3 h-8 w-8 text-neutral-700" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg>
|
|
55
|
+
<p class="text-sm text-neutral-500">No canned responses yet</p>
|
|
56
|
+
<p class="mt-1 text-xs text-neutral-600">Create reusable templates for common replies</p>
|
|
57
|
+
</div>
|
|
58
|
+
<div v-for="resp in responses" :key="resp.id" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4 transition-colors hover:bg-gray-900/80">
|
|
45
59
|
<div class="mb-2 flex items-center justify-between">
|
|
46
|
-
<div>
|
|
47
|
-
<span class="font-medium text-
|
|
48
|
-
<span v-if="resp.category" class="
|
|
49
|
-
</div>
|
|
50
|
-
<div>
|
|
51
|
-
<button @click="destroy(resp.id)" class="text-sm text-red-600 hover:text-red-900">Delete</button>
|
|
60
|
+
<div class="flex items-center gap-2">
|
|
61
|
+
<span class="font-medium text-neutral-200">{{ resp.title }}</span>
|
|
62
|
+
<span v-if="resp.category" class="rounded-md bg-white/[0.06] px-2 py-0.5 text-xs text-neutral-400 ring-1 ring-white/[0.06]">{{ resp.category }}</span>
|
|
52
63
|
</div>
|
|
64
|
+
<button @click="destroy(resp.id)" class="text-sm text-rose-400 hover:text-rose-300">Delete</button>
|
|
53
65
|
</div>
|
|
54
|
-
<p class="text-sm text-
|
|
66
|
+
<p class="text-sm text-neutral-400">{{ resp.body }}</p>
|
|
55
67
|
</div>
|
|
56
68
|
</div>
|
|
57
69
|
</EscalatedLayout>
|
|
@@ -22,26 +22,27 @@ function submit() {
|
|
|
22
22
|
|
|
23
23
|
<template>
|
|
24
24
|
<EscalatedLayout :title="department ? 'Edit Department' : 'New Department'">
|
|
25
|
-
<form @submit.prevent="submit" class="mx-auto max-w-lg space-y-
|
|
25
|
+
<form @submit.prevent="submit" class="mx-auto max-w-lg space-y-5 rounded-xl border border-white/[0.06] bg-neutral-900/60 p-6">
|
|
26
26
|
<div>
|
|
27
|
-
<label class="block text-sm font-medium text-
|
|
28
|
-
<input v-model="form.name" type="text" required class="mt-1 w-full rounded-lg border-
|
|
29
|
-
<div v-if="form.errors.name" class="mt-1 text-sm text-
|
|
27
|
+
<label class="block text-sm font-medium text-neutral-300">Name</label>
|
|
28
|
+
<input v-model="form.name" type="text" required class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10" />
|
|
29
|
+
<div v-if="form.errors.name" class="mt-1 text-sm text-rose-400">{{ form.errors.name }}</div>
|
|
30
30
|
</div>
|
|
31
31
|
<div>
|
|
32
|
-
<label class="block text-sm font-medium text-
|
|
33
|
-
<input v-model="form.slug" type="text" class="mt-1 w-full rounded-lg border-
|
|
32
|
+
<label class="block text-sm font-medium text-neutral-300">Slug</label>
|
|
33
|
+
<input v-model="form.slug" type="text" placeholder="Auto-generated if empty" class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10" />
|
|
34
34
|
</div>
|
|
35
35
|
<div>
|
|
36
|
-
<label class="block text-sm font-medium text-
|
|
37
|
-
<textarea v-model="form.description" rows="3" class="mt-1 w-full rounded-lg border-
|
|
36
|
+
<label class="block text-sm font-medium text-neutral-300">Description</label>
|
|
37
|
+
<textarea v-model="form.description" rows="3" class="mt-1 w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"></textarea>
|
|
38
38
|
</div>
|
|
39
39
|
<label class="flex items-center gap-2">
|
|
40
|
-
<input v-model="form.is_active" type="checkbox" class="rounded border-
|
|
41
|
-
<span class="text-sm text-
|
|
40
|
+
<input v-model="form.is_active" type="checkbox" class="rounded border-white/20 bg-neutral-900 text-white focus:ring-white/10" />
|
|
41
|
+
<span class="text-sm text-neutral-300">Active</span>
|
|
42
42
|
</label>
|
|
43
43
|
<div class="flex justify-end">
|
|
44
|
-
<button type="submit" :disabled="form.processing"
|
|
44
|
+
<button type="submit" :disabled="form.processing"
|
|
45
|
+
class="rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-5 py-2 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400 disabled:opacity-50">
|
|
45
46
|
{{ department ? 'Update' : 'Create' }}
|
|
46
47
|
</button>
|
|
47
48
|
</div>
|