@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,30 +1,60 @@
1
- <script setup>
2
- import AttachmentList from './AttachmentList.vue';
3
-
4
- defineProps({
5
- replies: { type: Array, required: true },
6
- currentUserId: { type: [Number, String], default: null },
7
- });
8
-
9
- function formatDate(date) {
10
- return new Date(date).toLocaleString();
11
- }
12
- </script>
13
-
14
- <template>
15
- <div class="space-y-4">
16
- <div v-for="reply in replies" :key="reply.id"
17
- :class="['rounded-lg border p-4', reply.is_internal_note ? 'border-yellow-200 bg-yellow-50' : 'border-gray-200 bg-white']">
18
- <div class="mb-2 flex items-center justify-between">
19
- <div class="flex items-center gap-2">
20
- <span class="font-medium text-gray-900">{{ reply.author?.name || 'Unknown' }}</span>
21
- <span v-if="reply.is_internal_note" class="rounded bg-yellow-200 px-1.5 py-0.5 text-xs font-medium text-yellow-800">Internal Note</span>
22
- </div>
23
- <span class="text-xs text-gray-500">{{ formatDate(reply.created_at) }}</span>
24
- </div>
25
- <div class="prose prose-sm max-w-none text-gray-700" v-html="reply.body"></div>
26
- <AttachmentList v-if="reply.attachments?.length" :attachments="reply.attachments" class="mt-3" />
27
- </div>
28
- <div v-if="!replies?.length" class="py-8 text-center text-sm text-gray-500">No replies yet.</div>
29
- </div>
30
- </template>
1
+ <script setup>
2
+ import { inject, computed } from 'vue';
3
+ import { router } from '@inertiajs/vue3';
4
+ import AttachmentList from './AttachmentList.vue';
5
+
6
+ const props = defineProps({
7
+ replies: { type: Array, required: true },
8
+ currentUserId: { type: [Number, String], default: null },
9
+ ticketReference: { type: String, default: '' },
10
+ routePrefix: { type: String, default: '' },
11
+ pinnable: { type: Boolean, default: false },
12
+ });
13
+
14
+ const escDark = inject('esc-dark', computed(() => false));
15
+
16
+ function formatDate(date) {
17
+ return new Date(date).toLocaleString();
18
+ }
19
+
20
+ function togglePin(reply) {
21
+ if (!props.routePrefix || !props.ticketReference) return;
22
+ router.post(route(`${props.routePrefix}.tickets.pin`, [props.ticketReference, reply.id]), {}, { preserveScroll: true });
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <div class="space-y-4">
28
+ <div v-for="reply in replies" :key="reply.id"
29
+ :class="['rounded-xl border p-4', escDark
30
+ ? (reply.is_internal_note ? 'border-amber-500/20 bg-amber-500/5' : 'border-white/[0.06] bg-neutral-900/60')
31
+ : (reply.is_internal_note ? 'border-yellow-200 bg-yellow-50' : 'border-gray-200 bg-white')]">
32
+ <div class="mb-2 flex items-center justify-between">
33
+ <div class="flex items-center gap-2">
34
+ <span :class="['font-medium', escDark ? 'text-gray-200' : 'text-gray-900']">{{ reply.author?.name || 'Unknown' }}</span>
35
+ <span v-if="reply.is_internal_note"
36
+ :class="['rounded px-1.5 py-0.5 text-xs font-medium', escDark ? 'bg-amber-500/15 text-amber-400' : 'bg-yellow-200 text-yellow-800']">
37
+ Internal Note
38
+ </span>
39
+ <span v-if="reply.is_pinned"
40
+ :class="['rounded px-1.5 py-0.5 text-xs font-medium', escDark ? 'bg-cyan-500/15 text-cyan-400' : 'bg-blue-100 text-blue-700']">
41
+ Pinned
42
+ </span>
43
+ </div>
44
+ <div class="flex items-center gap-2">
45
+ <button v-if="pinnable && reply.is_internal_note" @click="togglePin(reply)"
46
+ :class="['rounded px-2 py-0.5 text-xs font-medium transition-colors',
47
+ escDark
48
+ ? (reply.is_pinned ? 'bg-cyan-500/15 text-cyan-400 hover:bg-cyan-500/25' : 'text-neutral-500 hover:bg-white/[0.06] hover:text-neutral-300')
49
+ : (reply.is_pinned ? 'bg-blue-100 text-blue-700 hover:bg-blue-200' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600')]">
50
+ {{ reply.is_pinned ? 'Unpin' : 'Pin' }}
51
+ </button>
52
+ <span :class="['text-xs', escDark ? 'text-gray-500' : 'text-gray-500']">{{ formatDate(reply.created_at) }}</span>
53
+ </div>
54
+ </div>
55
+ <div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-gray-300' : 'text-gray-700']" v-html="reply.body"></div>
56
+ <AttachmentList v-if="reply.attachments?.length" :attachments="reply.attachments" class="mt-3" />
57
+ </div>
58
+ <div v-if="!replies?.length" :class="['py-8 text-center text-sm', escDark ? 'text-gray-500' : 'text-gray-500']">No replies yet.</div>
59
+ </div>
60
+ </template>
@@ -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,5 +1,5 @@
1
1
  <script setup>
2
- import { computed } from 'vue';
2
+ import { computed, inject } from 'vue';
3
3
 
4
4
  const props = defineProps({
5
5
  dueAt: { type: String, default: null },
@@ -7,6 +7,8 @@ const props = defineProps({
7
7
  label: { type: String, default: 'Due' },
8
8
  });
9
9
 
10
+ const dark = inject('esc-dark', computed(() => false));
11
+
10
12
  const timeRemaining = computed(() => {
11
13
  if (!props.dueAt) return null;
12
14
  const now = new Date();
@@ -24,19 +26,23 @@ const timeRemaining = computed(() => {
24
26
  });
25
27
 
26
28
  const statusClass = computed(() => {
27
- if (props.breached || timeRemaining.value?.overdue) return 'border-red-300 bg-red-50 text-red-700';
29
+ if (props.breached || timeRemaining.value?.overdue) {
30
+ return dark ? 'border-rose-500/20 bg-rose-500/10 text-rose-400' : 'border-red-300 bg-red-50 text-red-700';
31
+ }
28
32
  const due = new Date(props.dueAt);
29
33
  const hoursLeft = (due - new Date()) / 3600000;
30
- if (hoursLeft < 2) return 'border-yellow-300 bg-yellow-50 text-yellow-700';
31
- return 'border-green-300 bg-green-50 text-green-700';
34
+ if (hoursLeft < 2) {
35
+ return dark ? 'border-amber-500/20 bg-amber-500/10 text-amber-400' : 'border-yellow-300 bg-yellow-50 text-yellow-700';
36
+ }
37
+ return dark ? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400' : 'border-green-300 bg-green-50 text-green-700';
32
38
  });
33
39
  </script>
34
40
 
35
41
  <template>
36
- <div v-if="dueAt" :class="['rounded-md border px-3 py-2 text-sm', statusClass]">
42
+ <div v-if="dueAt" :class="['rounded-lg border px-3 py-2 text-sm', statusClass]">
37
43
  <div class="font-medium">{{ label }}</div>
38
44
  <div class="text-xs">
39
- <span v-if="breached">⚠ SLA Breached</span>
45
+ <span v-if="breached">SLA Breached</span>
40
46
  <span v-else>{{ timeRemaining?.text }}</span>
41
47
  </div>
42
48
  </div>
@@ -1,25 +1,45 @@
1
1
  <script setup>
2
- defineProps({
3
- label: { type: String, required: true },
2
+ import { inject, computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ label: { type: String, default: '' },
6
+ title: { type: String, default: '' },
4
7
  value: { type: [Number, String], required: true },
5
8
  trend: { type: String, default: null },
6
9
  color: { type: String, default: 'blue' },
7
10
  });
8
11
 
9
- const colorMap = {
12
+ const escDark = inject('esc-dark', computed(() => false));
13
+ const displayLabel = computed(() => props.label || props.title || '');
14
+
15
+ const lightColorMap = {
10
16
  blue: 'bg-blue-50 text-blue-700',
17
+ indigo: 'bg-indigo-50 text-indigo-700',
11
18
  green: 'bg-green-50 text-green-700',
12
19
  red: 'bg-red-50 text-red-700',
13
20
  yellow: 'bg-yellow-50 text-yellow-700',
14
21
  gray: 'bg-gray-50 text-gray-700',
22
+ cyan: 'bg-cyan-50 text-cyan-700',
23
+ violet: 'bg-violet-50 text-violet-700',
24
+ amber: 'bg-amber-50 text-amber-700',
15
25
  };
16
26
  </script>
17
27
 
18
28
  <template>
19
- <div class="rounded-lg border border-gray-200 bg-white p-4">
20
- <div class="text-sm text-gray-500">{{ label }}</div>
29
+ <!-- Dark mode -->
30
+ <div v-if="escDark" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-5">
31
+ <div class="text-[13px] font-medium text-neutral-500">{{ displayLabel }}</div>
32
+ <div class="mt-2 text-2xl font-bold tracking-tight text-white">{{ value }}</div>
33
+ <div v-if="trend" class="mt-2 inline-flex items-center rounded-full bg-white/[0.06] px-2 py-0.5 text-xs font-medium text-neutral-400">
34
+ {{ trend }}
35
+ </div>
36
+ </div>
37
+
38
+ <!-- Light mode -->
39
+ <div v-else class="rounded-lg border border-gray-200 bg-white p-4">
40
+ <div class="text-sm text-gray-500">{{ displayLabel }}</div>
21
41
  <div class="mt-1 text-2xl font-bold text-gray-900">{{ value }}</div>
22
- <div v-if="trend" :class="['mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium', colorMap[color] || colorMap.blue]">
42
+ <div v-if="trend" :class="['mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium', lightColorMap[color] || lightColorMap.blue]">
23
43
  {{ trend }}
24
44
  </div>
25
45
  </div>
@@ -1,9 +1,24 @@
1
1
  <script setup>
2
+ import { inject, computed } from 'vue';
3
+
2
4
  const props = defineProps({
3
5
  status: { type: String, required: true },
4
6
  });
5
7
 
6
- const statusConfig = {
8
+ const escDark = inject('esc-dark', computed(() => false));
9
+
10
+ const darkConfig = {
11
+ open: { label: 'Open', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
12
+ in_progress: { label: 'In Progress', color: 'bg-violet-500/10 text-violet-400 ring-1 ring-violet-500/20' },
13
+ waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
14
+ waiting_on_agent: { label: 'Waiting on Agent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
15
+ escalated: { label: 'Escalated', color: 'bg-rose-500/10 text-rose-400 ring-1 ring-rose-500/20' },
16
+ resolved: { label: 'Resolved', color: 'bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20' },
17
+ closed: { label: 'Closed', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
18
+ reopened: { label: 'Reopened', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
19
+ };
20
+
21
+ const lightConfig = {
7
22
  open: { label: 'Open', color: 'bg-blue-100 text-blue-800' },
8
23
  in_progress: { label: 'In Progress', color: 'bg-purple-100 text-purple-800' },
9
24
  waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-yellow-100 text-yellow-800' },
@@ -14,7 +29,14 @@ const statusConfig = {
14
29
  reopened: { label: 'Reopened', color: 'bg-blue-100 text-blue-800' },
15
30
  };
16
31
 
17
- const config = statusConfig[props.status] || { label: props.status, color: 'bg-gray-100 text-gray-800' };
32
+ const fallback = { label: props.status, color: 'bg-gray-100 text-gray-800' };
33
+ const darkFallback = { label: props.status, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' };
34
+
35
+ const config = computed(() => {
36
+ const map = escDark.value ? darkConfig : lightConfig;
37
+ const fb = escDark.value ? darkFallback : fallback;
38
+ return map[props.status] || fb;
39
+ });
18
40
  </script>
19
41
 
20
42
  <template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed } from 'vue';
2
+ import { ref, computed, inject } from 'vue';
3
3
 
4
4
  const props = defineProps({
5
5
  tags: { type: Array, required: true },
@@ -7,6 +7,7 @@ const props = defineProps({
7
7
  });
8
8
 
9
9
  const emit = defineEmits(['update:modelValue']);
10
+ const dark = inject('esc-dark', computed(() => false));
10
11
 
11
12
  const search = ref('');
12
13
 
@@ -34,13 +35,16 @@ function isSelected(tagId) {
34
35
 
35
36
  <template>
36
37
  <div>
37
- <label class="mb-1 block text-xs font-medium text-gray-600">Tags</label>
38
+ <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Tags</label>
38
39
  <input v-model="search" type="text" placeholder="Filter tags..."
39
- class="mb-2 w-full rounded-md border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none" />
40
+ :class="['mb-2 w-full rounded-md border px-2 py-1 text-xs focus:outline-none',
41
+ dark ? 'border-white/10 bg-neutral-950 text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']" />
40
42
  <div class="flex flex-wrap gap-1">
41
43
  <button v-for="tag in filteredTags" :key="tag.id" @click="toggle(tag.id)"
42
44
  :class="['rounded-full px-2 py-0.5 text-xs font-medium transition-colors',
43
- isSelected(tag.id) ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200']"
45
+ dark
46
+ ? (isSelected(tag.id) ? 'bg-white/[0.12] text-white ring-1 ring-white/20' : 'bg-white/[0.04] text-neutral-500 hover:bg-white/[0.08]')
47
+ : (isSelected(tag.id) ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200')]"
44
48
  :style="tag.color ? { backgroundColor: isSelected(tag.id) ? tag.color + '33' : undefined, color: isSelected(tag.id) ? tag.color : undefined } : {}">
45
49
  {{ tag.name }}
46
50
  </button>
@@ -1,52 +1,69 @@
1
- <script setup>
2
- import { 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
-
14
- const filters = reactive({
15
- status: props.modelValue.status || '',
16
- priority: props.modelValue.priority || '',
17
- assigned_to: props.modelValue.assigned_to || '',
18
- department_id: props.modelValue.department_id || '',
19
- search: props.modelValue.search || '',
20
- });
21
-
22
- watch(filters, (val) => {
23
- emit('update:modelValue', { ...val });
24
- }, { deep: true });
25
- </script>
26
-
27
- <template>
28
- <div class="flex flex-wrap items-center gap-3">
29
- <input v-model="filters.search" type="text" placeholder="Search tickets..."
30
- class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
31
-
32
- <select v-model="filters.status" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
33
- <option value="">All Statuses</option>
34
- <option v-for="s in statuses" :key="s" :value="s">{{ s.replace(/_/g, ' ') }}</option>
35
- </select>
36
-
37
- <select v-model="filters.priority" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
38
- <option value="">All Priorities</option>
39
- <option v-for="p in priorities" :key="p" :value="p">{{ p }}</option>
40
- </select>
41
-
42
- <select v-if="agents.length" v-model="filters.assigned_to" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
43
- <option value="">All Agents</option>
44
- <option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
45
- </select>
46
-
47
- <select v-if="departments.length" v-model="filters.department_id" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
48
- <option value="">All Departments</option>
49
- <option v-for="d in departments" :key="d.id" :value="d.id">{{ d.name }}</option>
50
- </select>
51
- </div>
52
- </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>