@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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActivityTimeline.vue +8 -4
  3. package/src/components/AssigneeSelect.vue +6 -2
  4. package/src/components/AttachmentList.vue +9 -3
  5. package/src/components/BulkActionBar.vue +97 -0
  6. package/src/components/EscalatedLayout.vue +101 -94
  7. package/src/components/FileDropzone.vue +9 -4
  8. package/src/components/FollowButton.vue +63 -0
  9. package/src/components/KeyboardShortcutHelp.vue +123 -0
  10. package/src/components/MacroDropdown.vue +90 -0
  11. package/src/components/PinnedNotes.vue +87 -0
  12. package/src/components/PresenceIndicator.vue +103 -0
  13. package/src/components/PriorityBadge.vue +20 -2
  14. package/src/components/QuickFilters.vue +49 -0
  15. package/src/components/ReplyComposer.vue +52 -3
  16. package/src/components/ReplyThread.vue +60 -30
  17. package/src/components/SatisfactionRating.vue +128 -0
  18. package/src/components/SlaTimer.vue +12 -6
  19. package/src/components/StatsCard.vue +26 -6
  20. package/src/components/StatusBadge.vue +24 -2
  21. package/src/components/TagSelect.vue +8 -4
  22. package/src/components/TicketFilters.vue +69 -52
  23. package/src/components/TicketList.vue +193 -51
  24. package/src/components/TicketSidebar.vue +99 -70
  25. package/src/composables/useKeyboardShortcuts.js +46 -0
  26. package/src/index.js +11 -0
  27. package/src/pages/Admin/CannedResponses/Index.vue +29 -17
  28. package/src/pages/Admin/Departments/Form.vue +12 -11
  29. package/src/pages/Admin/Departments/Index.vue +26 -18
  30. package/src/pages/Admin/EscalationRules/Form.vue +21 -20
  31. package/src/pages/Admin/EscalationRules/Index.vue +26 -18
  32. package/src/pages/Admin/Macros/Index.vue +287 -0
  33. package/src/pages/Admin/Reports.vue +101 -52
  34. package/src/pages/Admin/Settings.vue +260 -0
  35. package/src/pages/Admin/SlaPolicies/Form.vue +21 -20
  36. package/src/pages/Admin/SlaPolicies/Index.vue +28 -20
  37. package/src/pages/Admin/Tags/Index.vue +48 -23
  38. package/src/pages/Admin/Tickets/Index.vue +39 -0
  39. package/src/pages/Admin/Tickets/Show.vue +145 -0
  40. package/src/pages/Agent/Dashboard.vue +156 -51
  41. package/src/pages/Agent/TicketIndex.vue +38 -21
  42. package/src/pages/Agent/TicketShow.vue +144 -108
  43. package/src/pages/Customer/Show.vue +63 -55
  44. package/src/pages/Guest/Create.vue +97 -0
  45. package/src/pages/Guest/Show.vue +93 -0
@@ -1,51 +1,193 @@
1
- <script setup>
2
- import StatusBadge from './StatusBadge.vue';
3
- import PriorityBadge from './PriorityBadge.vue';
4
- import { Link } from '@inertiajs/vue3';
5
-
6
- defineProps({
7
- tickets: { type: Object, required: true },
8
- routePrefix: { type: String, default: 'escalated.customer.tickets' },
9
- showAssignee: { type: Boolean, default: false },
10
- });
11
- </script>
12
-
13
- <template>
14
- <div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
15
- <table class="min-w-full divide-y divide-gray-200">
16
- <thead class="bg-gray-50">
17
- <tr>
18
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Reference</th>
19
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Subject</th>
20
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Status</th>
21
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
22
- <th v-if="showAssignee" class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Assignee</th>
23
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Created</th>
24
- </tr>
25
- </thead>
26
- <tbody class="divide-y divide-gray-200">
27
- <tr v-for="ticket in tickets.data" :key="ticket.id" class="hover:bg-gray-50">
28
- <td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900">
29
- <Link :href="route(`.show`, ticket.reference)" class="text-indigo-600 hover:text-indigo-900">
30
- {{ ticket.reference }}
31
- </Link>
32
- </td>
33
- <td class="px-4 py-3 text-sm text-gray-900">{{ ticket.subject }}</td>
34
- <td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
35
- <td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
36
- <td v-if="showAssignee" class="px-4 py-3 text-sm text-gray-500">
37
- {{ ticket.assignee?.name || 'Unassigned' }}
38
- </td>
39
- <td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
40
- {{ new Date(ticket.created_at).toLocaleDateString() }}
41
- </td>
42
- </tr>
43
- <tr v-if="!tickets.data?.length">
44
- <td :colspan="showAssignee ? 6 : 5" class="px-4 py-8 text-center text-sm text-gray-500">
45
- No tickets found.
46
- </td>
47
- </tr>
48
- </tbody>
49
- </table>
50
- </div>
51
- </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>
@@ -1,70 +1,99 @@
1
- <script setup>
2
- import StatusBadge from './StatusBadge.vue';
3
- import PriorityBadge from './PriorityBadge.vue';
4
- import SlaTimer from './SlaTimer.vue';
5
- import AssigneeSelect from './AssigneeSelect.vue';
6
- import TagSelect from './TagSelect.vue';
7
- import ActivityTimeline from './ActivityTimeline.vue';
8
-
9
- defineProps({
10
- ticket: { type: Object, required: true },
11
- agents: { type: Array, default: () => [] },
12
- tags: { type: Array, default: () => [] },
13
- activities: { type: Array, default: () => [] },
14
- editable: { type: Boolean, default: false },
15
- });
16
-
17
- const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status']);
18
- </script>
19
-
20
- <template>
21
- <aside class="space-y-4">
22
- <div class="rounded-lg border border-gray-200 bg-white p-4">
23
- <h3 class="mb-3 text-sm font-semibold text-gray-900">Details</h3>
24
- <dl class="space-y-2 text-sm">
25
- <div class="flex justify-between">
26
- <dt class="text-gray-500">Status</dt>
27
- <dd><StatusBadge :status="ticket.status" /></dd>
28
- </div>
29
- <div class="flex justify-between">
30
- <dt class="text-gray-500">Priority</dt>
31
- <dd><PriorityBadge :priority="ticket.priority" /></dd>
32
- </div>
33
- <div class="flex justify-between">
34
- <dt class="text-gray-500">Reference</dt>
35
- <dd class="font-mono text-xs">{{ ticket.reference }}</dd>
36
- </div>
37
- <div v-if="ticket.department" class="flex justify-between">
38
- <dt class="text-gray-500">Department</dt>
39
- <dd>{{ ticket.department.name }}</dd>
40
- </div>
41
- <div class="flex justify-between">
42
- <dt class="text-gray-500">Created</dt>
43
- <dd>{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
44
- </div>
45
- </dl>
46
- </div>
47
-
48
- <div v-if="ticket.first_response_due_at || ticket.resolution_due_at" class="space-y-2">
49
- <SlaTimer v-if="ticket.first_response_due_at" :due-at="ticket.first_response_due_at"
50
- :breached="ticket.sla_first_response_breached" label="First Response" />
51
- <SlaTimer v-if="ticket.resolution_due_at" :due-at="ticket.resolution_due_at"
52
- :breached="ticket.sla_resolution_breached" label="Resolution" />
53
- </div>
54
-
55
- <div v-if="editable && agents.length" class="rounded-lg border border-gray-200 bg-white p-4">
56
- <AssigneeSelect :agents="agents" :model-value="ticket.assigned_to"
57
- @update:model-value="emit('assign', $event)" />
58
- </div>
59
-
60
- <div v-if="editable && tags.length" class="rounded-lg border border-gray-200 bg-white p-4">
61
- <TagSelect :tags="tags" :model-value="(ticket.tags || []).map(t => t.id)"
62
- @update:model-value="emit('tags', $event)" />
63
- </div>
64
-
65
- <div v-if="activities.length" class="rounded-lg border border-gray-200 bg-white p-4">
66
- <h3 class="mb-3 text-sm font-semibold text-gray-900">Activity</h3>
67
- <ActivityTimeline :activities="activities" />
68
- </div>
69
- </aside>
70
- </template>
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" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
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-lg border border-gray-200 bg-white p-4">
32
- <input v-model="form.title" type="text" placeholder="Title" required class="w-full rounded-lg border-gray-300 shadow-sm" />
33
- <textarea v-model="form.body" rows="4" placeholder="Response body..." required class="w-full rounded-lg border-gray-300 shadow-sm"></textarea>
34
- <div class="flex gap-3">
35
- <input v-model="form.category" type="text" placeholder="Category (optional)" class="rounded-lg border-gray-300 shadow-sm" />
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-gray-300" />
38
- <span class="text-sm text-gray-700">Shared</span>
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" class="ml-auto rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">Create</button>
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-for="resp in responses" :key="resp.id" class="rounded-lg border border-gray-200 bg-white p-4">
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-gray-900">{{ resp.title }}</span>
48
- <span v-if="resp.category" class="ml-2 rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{{ resp.category }}</span>
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-gray-600">{{ resp.body }}</p>
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-4 rounded-lg border border-gray-200 bg-white p-6">
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-gray-700">Name</label>
28
- <input v-model="form.name" type="text" required class="mt-1 w-full rounded-lg border-gray-300 shadow-sm" />
29
- <div v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</div>
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-gray-700">Slug</label>
33
- <input v-model="form.slug" type="text" class="mt-1 w-full rounded-lg border-gray-300 shadow-sm" placeholder="Auto-generated if empty" />
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-gray-700">Description</label>
37
- <textarea v-model="form.description" rows="3" class="mt-1 w-full rounded-lg border-gray-300 shadow-sm"></textarea>
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-gray-300" />
41
- <span class="text-sm text-gray-700">Active</span>
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" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50">
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>