@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,30 +1,60 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<div v-
|
|
29
|
-
|
|
30
|
-
|
|
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)
|
|
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)
|
|
31
|
-
|
|
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-
|
|
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"
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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',
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
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
|
-
<option
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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>
|