@escalated-dev/escalated 0.2.1 → 0.3.5
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/EscalatedLayout.vue +219 -213
- package/src/components/FileDropzone.vue +9 -4
- package/src/components/PriorityBadge.vue +20 -2
- package/src/components/ReplyComposer.vue +52 -3
- package/src/components/ReplyThread.vue +14 -6
- 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 +17 -11
- package/src/components/TicketList.vue +45 -2
- package/src/components/TicketSidebar.vue +21 -14
- 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/Reports.vue +30 -16
- 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 +22 -0
- package/src/pages/Admin/Tickets/Show.vue +109 -0
- package/src/pages/Agent/Dashboard.vue +37 -25
- package/src/pages/Agent/TicketShow.vue +12 -12
- package/src/pages/Customer/Show.vue +2 -2
- package/src/pages/Guest/Create.vue +97 -0
- package/src/pages/Guest/Show.vue +86 -0
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
3
|
+
|
|
2
4
|
const props = defineProps({
|
|
3
5
|
priority: { type: String, required: true },
|
|
4
6
|
});
|
|
5
7
|
|
|
6
|
-
const
|
|
8
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
9
|
+
|
|
10
|
+
const darkConfig = {
|
|
11
|
+
low: { label: 'Low', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
|
|
12
|
+
medium: { label: 'Medium', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
|
|
13
|
+
high: { label: 'High', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
|
|
14
|
+
urgent: { label: 'Urgent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
|
|
15
|
+
critical: { label: 'Critical', color: 'bg-rose-500/10 text-rose-300 ring-1 ring-rose-500/20' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const lightConfig = {
|
|
7
19
|
low: { label: 'Low', color: 'bg-gray-100 text-gray-800' },
|
|
8
20
|
medium: { label: 'Medium', color: 'bg-blue-100 text-blue-800' },
|
|
9
21
|
high: { label: 'High', color: 'bg-yellow-100 text-yellow-800' },
|
|
@@ -11,7 +23,13 @@ const priorityConfig = {
|
|
|
11
23
|
critical: { label: 'Critical', color: 'bg-red-100 text-red-800' },
|
|
12
24
|
};
|
|
13
25
|
|
|
14
|
-
const config =
|
|
26
|
+
const config = computed(() => {
|
|
27
|
+
const map = escDark.value ? darkConfig : lightConfig;
|
|
28
|
+
const fallback = escDark.value
|
|
29
|
+
? { label: props.priority, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' }
|
|
30
|
+
: { label: props.priority, color: 'bg-gray-100 text-gray-800' };
|
|
31
|
+
return map[props.priority] || fallback;
|
|
32
|
+
});
|
|
15
33
|
</script>
|
|
16
34
|
|
|
17
35
|
<template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue';
|
|
2
|
+
import { ref, computed, inject } from 'vue';
|
|
3
3
|
import { router } from '@inertiajs/vue3';
|
|
4
4
|
import FileDropzone from './FileDropzone.vue';
|
|
5
5
|
|
|
@@ -12,6 +12,7 @@ const props = defineProps({
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
const emit = defineEmits(['submit']);
|
|
15
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
15
16
|
|
|
16
17
|
const body = ref('');
|
|
17
18
|
const isNote = ref(false);
|
|
@@ -62,7 +63,55 @@ const buttonLabel = computed(() => {
|
|
|
62
63
|
</script>
|
|
63
64
|
|
|
64
65
|
<template>
|
|
65
|
-
|
|
66
|
+
<!-- Dark mode -->
|
|
67
|
+
<div v-if="escDark" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4">
|
|
68
|
+
<div v-if="allowNotes" class="mb-3 flex gap-2">
|
|
69
|
+
<button @click="isNote = false"
|
|
70
|
+
:class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', !isNote ? 'bg-cyan-500/15 text-white ring-1 ring-cyan-500/20' : 'text-gray-400 hover:bg-white/[0.04]']">
|
|
71
|
+
Reply
|
|
72
|
+
</button>
|
|
73
|
+
<button @click="isNote = true"
|
|
74
|
+
:class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', isNote ? 'bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20' : 'text-gray-400 hover:bg-white/[0.04]']">
|
|
75
|
+
Internal Note
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div v-if="isNote" class="mb-2 rounded-lg bg-amber-500/10 px-3 py-1.5 text-xs text-amber-400 ring-1 ring-amber-500/20">
|
|
80
|
+
This note is only visible to agents.
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<textarea v-model="body" rows="4" :placeholder="placeholder"
|
|
84
|
+
class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"></textarea>
|
|
85
|
+
|
|
86
|
+
<FileDropzone @files="handleFiles" class="mt-2" />
|
|
87
|
+
|
|
88
|
+
<div v-if="files.length" class="mt-2 space-y-1">
|
|
89
|
+
<div v-for="(file, i) in files" :key="i" class="flex items-center gap-2 text-sm text-gray-400">
|
|
90
|
+
<span>{{ file.name }}</span>
|
|
91
|
+
<button @click="removeFile(i)" class="text-rose-400 hover:text-rose-300">×</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="mt-3 flex items-center justify-between">
|
|
96
|
+
<div v-if="cannedResponses.length">
|
|
97
|
+
<select @change="insertCanned(cannedResponses[$event.target.value]); $event.target.value = ''"
|
|
98
|
+
class="rounded-lg border border-white/10 bg-gray-900 px-2 py-1 text-xs text-gray-400">
|
|
99
|
+
<option value="">Canned responses...</option>
|
|
100
|
+
<option v-for="(cr, i) in cannedResponses" :key="cr.id" :value="i">{{ cr.title }}</option>
|
|
101
|
+
</select>
|
|
102
|
+
</div>
|
|
103
|
+
<div v-else></div>
|
|
104
|
+
<button @click="submit" :disabled="!body.trim() || submitting"
|
|
105
|
+
:class="['rounded-lg px-4 py-2 text-sm font-medium text-white transition-all',
|
|
106
|
+
isNote ? 'bg-amber-500 hover:bg-amber-400' : 'bg-gradient-to-r from-cyan-500 to-violet-500 hover:from-cyan-400 hover:to-violet-400',
|
|
107
|
+
(!body.trim() || submitting) && 'cursor-not-allowed opacity-40']">
|
|
108
|
+
{{ buttonLabel }}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<!-- Light mode -->
|
|
114
|
+
<div v-else class="rounded-lg border border-gray-200 bg-white p-4">
|
|
66
115
|
<div v-if="allowNotes" class="mb-3 flex gap-2">
|
|
67
116
|
<button @click="isNote = false"
|
|
68
117
|
:class="['rounded-md px-3 py-1 text-sm font-medium', !isNote ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-100']">
|
|
@@ -91,7 +140,7 @@ const buttonLabel = computed(() => {
|
|
|
91
140
|
</div>
|
|
92
141
|
|
|
93
142
|
<div class="mt-3 flex items-center justify-between">
|
|
94
|
-
<div v-if="cannedResponses.length"
|
|
143
|
+
<div v-if="cannedResponses.length">
|
|
95
144
|
<select @change="insertCanned(cannedResponses[$event.target.value]); $event.target.value = ''"
|
|
96
145
|
class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-600">
|
|
97
146
|
<option value="">Canned responses...</option>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
2
3
|
import AttachmentList from './AttachmentList.vue';
|
|
3
4
|
|
|
4
5
|
defineProps({
|
|
@@ -6,6 +7,8 @@ defineProps({
|
|
|
6
7
|
currentUserId: { type: [Number, String], default: null },
|
|
7
8
|
});
|
|
8
9
|
|
|
10
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
11
|
+
|
|
9
12
|
function formatDate(date) {
|
|
10
13
|
return new Date(date).toLocaleString();
|
|
11
14
|
}
|
|
@@ -14,17 +17,22 @@ function formatDate(date) {
|
|
|
14
17
|
<template>
|
|
15
18
|
<div class="space-y-4">
|
|
16
19
|
<div v-for="reply in replies" :key="reply.id"
|
|
17
|
-
:class="['rounded-
|
|
20
|
+
:class="['rounded-xl border p-4', escDark
|
|
21
|
+
? (reply.is_internal_note ? 'border-amber-500/20 bg-amber-500/5' : 'border-white/[0.06] bg-neutral-900/60')
|
|
22
|
+
: (reply.is_internal_note ? 'border-yellow-200 bg-yellow-50' : 'border-gray-200 bg-white')]">
|
|
18
23
|
<div class="mb-2 flex items-center justify-between">
|
|
19
24
|
<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"
|
|
25
|
+
<span :class="['font-medium', escDark ? 'text-gray-200' : 'text-gray-900']">{{ reply.author?.name || 'Unknown' }}</span>
|
|
26
|
+
<span v-if="reply.is_internal_note"
|
|
27
|
+
: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']">
|
|
28
|
+
Internal Note
|
|
29
|
+
</span>
|
|
22
30
|
</div>
|
|
23
|
-
<span class="text-xs text-gray-500">{{ formatDate(reply.created_at) }}</span>
|
|
31
|
+
<span :class="['text-xs', escDark ? 'text-gray-500' : 'text-gray-500']">{{ formatDate(reply.created_at) }}</span>
|
|
24
32
|
</div>
|
|
25
|
-
<div class="prose prose-sm max-w-none text-gray-700" v-html="reply.body"></div>
|
|
33
|
+
<div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-gray-300' : 'text-gray-700']" v-html="reply.body"></div>
|
|
26
34
|
<AttachmentList v-if="reply.attachments?.length" :attachments="reply.attachments" class="mt-3" />
|
|
27
35
|
</div>
|
|
28
|
-
<div v-if="!replies?.length" class="py-8 text-center text-sm text-gray-500">No replies yet.</div>
|
|
36
|
+
<div v-if="!replies?.length" :class="['py-8 text-center text-sm', escDark ? 'text-gray-500' : 'text-gray-500']">No replies yet.</div>
|
|
29
37
|
</div>
|
|
30
38
|
</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,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { reactive, watch } from 'vue';
|
|
2
|
+
import { inject, computed, reactive, watch } from 'vue';
|
|
3
3
|
|
|
4
4
|
const props = defineProps({
|
|
5
5
|
statuses: { type: Array, default: () => ['open', 'in_progress', 'waiting_on_customer', 'waiting_on_agent', 'escalated', 'resolved', 'closed'] },
|
|
@@ -10,6 +10,7 @@ const props = defineProps({
|
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
const emit = defineEmits(['update:modelValue']);
|
|
13
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
13
14
|
|
|
14
15
|
const filters = reactive({
|
|
15
16
|
status: props.modelValue.status || '',
|
|
@@ -22,29 +23,34 @@ const filters = reactive({
|
|
|
22
23
|
watch(filters, (val) => {
|
|
23
24
|
emit('update:modelValue', { ...val });
|
|
24
25
|
}, { deep: true });
|
|
26
|
+
|
|
27
|
+
const inputClass = computed(() => escDark.value
|
|
28
|
+
? 'rounded-lg border border-white/10 bg-gray-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10'
|
|
29
|
+
: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none'
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const selectClass = computed(() => escDark.value
|
|
33
|
+
? 'rounded-lg border border-white/10 bg-gray-900 px-2 py-1.5 text-sm text-gray-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10'
|
|
34
|
+
: 'rounded-md border border-gray-300 px-2 py-1.5 text-sm'
|
|
35
|
+
);
|
|
25
36
|
</script>
|
|
26
37
|
|
|
27
38
|
<template>
|
|
28
39
|
<div class="flex flex-wrap items-center gap-3">
|
|
29
|
-
<input v-model="filters.search" type="text" placeholder="Search tickets..."
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<select v-model="filters.status" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
|
|
40
|
+
<input v-model="filters.search" type="text" placeholder="Search tickets..." :class="inputClass" />
|
|
41
|
+
<select v-model="filters.status" :class="selectClass">
|
|
33
42
|
<option value="">All Statuses</option>
|
|
34
43
|
<option v-for="s in statuses" :key="s" :value="s">{{ s.replace(/_/g, ' ') }}</option>
|
|
35
44
|
</select>
|
|
36
|
-
|
|
37
|
-
<select v-model="filters.priority" class="rounded-md border border-gray-300 px-2 py-1.5 text-sm">
|
|
45
|
+
<select v-model="filters.priority" :class="selectClass">
|
|
38
46
|
<option value="">All Priorities</option>
|
|
39
47
|
<option v-for="p in priorities" :key="p" :value="p">{{ p }}</option>
|
|
40
48
|
</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">
|
|
49
|
+
<select v-if="agents.length" v-model="filters.assigned_to" :class="selectClass">
|
|
43
50
|
<option value="">All Agents</option>
|
|
44
51
|
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
|
45
52
|
</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">
|
|
53
|
+
<select v-if="departments.length" v-model="filters.department_id" :class="selectClass">
|
|
48
54
|
<option value="">All Departments</option>
|
|
49
55
|
<option v-for="d in departments" :key="d.id" :value="d.id">{{ d.name }}</option>
|
|
50
56
|
</select>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
2
3
|
import StatusBadge from './StatusBadge.vue';
|
|
3
4
|
import PriorityBadge from './PriorityBadge.vue';
|
|
4
5
|
import { Link } from '@inertiajs/vue3';
|
|
@@ -8,10 +9,52 @@ defineProps({
|
|
|
8
9
|
routePrefix: { type: String, default: 'escalated.customer.tickets' },
|
|
9
10
|
showAssignee: { type: Boolean, default: false },
|
|
10
11
|
});
|
|
12
|
+
|
|
13
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
11
14
|
</script>
|
|
12
15
|
|
|
13
16
|
<template>
|
|
14
|
-
|
|
17
|
+
<!-- Dark mode -->
|
|
18
|
+
<div v-if="escDark" class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
|
|
19
|
+
<table class="min-w-full divide-y divide-white/[0.06]">
|
|
20
|
+
<thead>
|
|
21
|
+
<tr class="bg-white/[0.02]">
|
|
22
|
+
<th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Reference</th>
|
|
23
|
+
<th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Subject</th>
|
|
24
|
+
<th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Status</th>
|
|
25
|
+
<th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Priority</th>
|
|
26
|
+
<th v-if="showAssignee" class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Assignee</th>
|
|
27
|
+
<th class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Created</th>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody class="divide-y divide-white/[0.04]">
|
|
31
|
+
<tr v-for="ticket in tickets.data" :key="ticket.id" class="transition-colors hover:bg-white/[0.03]">
|
|
32
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium">
|
|
33
|
+
<Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-white hover:text-neutral-300">
|
|
34
|
+
{{ ticket.reference }}
|
|
35
|
+
</Link>
|
|
36
|
+
</td>
|
|
37
|
+
<td class="px-4 py-3 text-sm text-neutral-300">{{ ticket.subject }}</td>
|
|
38
|
+
<td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
|
|
39
|
+
<td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
|
|
40
|
+
<td v-if="showAssignee" class="px-4 py-3 text-sm text-neutral-500">
|
|
41
|
+
{{ ticket.assignee?.name || 'Unassigned' }}
|
|
42
|
+
</td>
|
|
43
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-600">
|
|
44
|
+
{{ new Date(ticket.created_at).toLocaleDateString() }}
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
<tr v-if="!tickets.data?.length">
|
|
48
|
+
<td :colspan="showAssignee ? 6 : 5" class="px-4 py-8 text-center text-sm text-neutral-600">
|
|
49
|
+
No tickets found.
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- Light mode -->
|
|
57
|
+
<div v-else class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
|
15
58
|
<table class="min-w-full divide-y divide-gray-200">
|
|
16
59
|
<thead class="bg-gray-50">
|
|
17
60
|
<tr>
|
|
@@ -26,7 +69,7 @@ defineProps({
|
|
|
26
69
|
<tbody class="divide-y divide-gray-200">
|
|
27
70
|
<tr v-for="ticket in tickets.data" :key="ticket.id" class="hover:bg-gray-50">
|
|
28
71
|
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900">
|
|
29
|
-
<Link :href="route(
|
|
72
|
+
<Link :href="route(`${routePrefix}.show`, ticket.reference)" class="text-indigo-600 hover:text-indigo-900">
|
|
30
73
|
{{ ticket.reference }}
|
|
31
74
|
</Link>
|
|
32
75
|
</td>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
2
3
|
import StatusBadge from './StatusBadge.vue';
|
|
3
4
|
import PriorityBadge from './PriorityBadge.vue';
|
|
4
5
|
import SlaTimer from './SlaTimer.vue';
|
|
@@ -15,32 +16,38 @@ defineProps({
|
|
|
15
16
|
});
|
|
16
17
|
|
|
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
|
+
);
|
|
18
25
|
</script>
|
|
19
26
|
|
|
20
27
|
<template>
|
|
21
28
|
<aside class="space-y-4">
|
|
22
|
-
<div class="
|
|
23
|
-
<h3 class="mb-3 text-sm font-semibold text-gray-900">Details</h3>
|
|
29
|
+
<div :class="cardClass">
|
|
30
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Details</h3>
|
|
24
31
|
<dl class="space-y-2 text-sm">
|
|
25
32
|
<div class="flex justify-between">
|
|
26
|
-
<dt class="text-gray-500">Status</dt>
|
|
33
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Status</dt>
|
|
27
34
|
<dd><StatusBadge :status="ticket.status" /></dd>
|
|
28
35
|
</div>
|
|
29
36
|
<div class="flex justify-between">
|
|
30
|
-
<dt class="text-gray-500">Priority</dt>
|
|
37
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Priority</dt>
|
|
31
38
|
<dd><PriorityBadge :priority="ticket.priority" /></dd>
|
|
32
39
|
</div>
|
|
33
40
|
<div class="flex justify-between">
|
|
34
|
-
<dt class="text-gray-500">Reference</dt>
|
|
35
|
-
<dd class="font-mono text-xs">{{ ticket.reference }}</dd>
|
|
41
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Reference</dt>
|
|
42
|
+
<dd :class="['font-mono text-xs', escDark ? 'text-white' : '']">{{ ticket.reference }}</dd>
|
|
36
43
|
</div>
|
|
37
44
|
<div v-if="ticket.department" class="flex justify-between">
|
|
38
|
-
<dt class="text-gray-500">Department</dt>
|
|
39
|
-
<dd>{{ ticket.department.name }}</dd>
|
|
45
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Department</dt>
|
|
46
|
+
<dd :class="escDark ? 'text-neutral-300' : ''">{{ ticket.department.name }}</dd>
|
|
40
47
|
</div>
|
|
41
48
|
<div class="flex justify-between">
|
|
42
|
-
<dt class="text-gray-500">Created</dt>
|
|
43
|
-
<dd>{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
|
|
49
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Created</dt>
|
|
50
|
+
<dd :class="escDark ? 'text-neutral-300' : ''">{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
|
|
44
51
|
</div>
|
|
45
52
|
</dl>
|
|
46
53
|
</div>
|
|
@@ -52,18 +59,18 @@ const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status'])
|
|
|
52
59
|
:breached="ticket.sla_resolution_breached" label="Resolution" />
|
|
53
60
|
</div>
|
|
54
61
|
|
|
55
|
-
<div v-if="editable && agents.length" class="
|
|
62
|
+
<div v-if="editable && agents.length" :class="cardClass">
|
|
56
63
|
<AssigneeSelect :agents="agents" :model-value="ticket.assigned_to"
|
|
57
64
|
@update:model-value="emit('assign', $event)" />
|
|
58
65
|
</div>
|
|
59
66
|
|
|
60
|
-
<div v-if="editable && tags.length" class="
|
|
67
|
+
<div v-if="editable && tags.length" :class="cardClass">
|
|
61
68
|
<TagSelect :tags="tags" :model-value="(ticket.tags || []).map(t => t.id)"
|
|
62
69
|
@update:model-value="emit('tags', $event)" />
|
|
63
70
|
</div>
|
|
64
71
|
|
|
65
|
-
<div v-if="activities.length" class="
|
|
66
|
-
<h3 class="mb-3 text-sm font-semibold text-gray-900">Activity</h3>
|
|
72
|
+
<div v-if="activities.length" :class="cardClass">
|
|
73
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Activity</h3>
|
|
67
74
|
<ActivityTimeline :activities="activities" />
|
|
68
75
|
</div>
|
|
69
76
|
</aside>
|
|
@@ -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>
|