@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,108 +1,144 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
-
import StatusBadge from '../../components/StatusBadge.vue';
|
|
4
|
-
import PriorityBadge from '../../components/PriorityBadge.vue';
|
|
5
|
-
import ReplyThread from '../../components/ReplyThread.vue';
|
|
6
|
-
import ReplyComposer from '../../components/ReplyComposer.vue';
|
|
7
|
-
import TicketSidebar from '../../components/TicketSidebar.vue';
|
|
8
|
-
import AttachmentList from '../../components/AttachmentList.vue';
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
</
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
+
import StatusBadge from '../../components/StatusBadge.vue';
|
|
4
|
+
import PriorityBadge from '../../components/PriorityBadge.vue';
|
|
5
|
+
import ReplyThread from '../../components/ReplyThread.vue';
|
|
6
|
+
import ReplyComposer from '../../components/ReplyComposer.vue';
|
|
7
|
+
import TicketSidebar from '../../components/TicketSidebar.vue';
|
|
8
|
+
import AttachmentList from '../../components/AttachmentList.vue';
|
|
9
|
+
import MacroDropdown from '../../components/MacroDropdown.vue';
|
|
10
|
+
import FollowButton from '../../components/FollowButton.vue';
|
|
11
|
+
import PresenceIndicator from '../../components/PresenceIndicator.vue';
|
|
12
|
+
import PinnedNotes from '../../components/PinnedNotes.vue';
|
|
13
|
+
import KeyboardShortcutHelp from '../../components/KeyboardShortcutHelp.vue';
|
|
14
|
+
import { useKeyboardShortcuts } from '../../composables/useKeyboardShortcuts';
|
|
15
|
+
import { router, useForm, usePage } from '@inertiajs/vue3';
|
|
16
|
+
import { ref } from 'vue';
|
|
17
|
+
|
|
18
|
+
const props = defineProps({
|
|
19
|
+
ticket: Object,
|
|
20
|
+
departments: Array,
|
|
21
|
+
tags: Array,
|
|
22
|
+
cannedResponses: Array,
|
|
23
|
+
macros: { type: Array, default: () => [] },
|
|
24
|
+
is_following: { type: Boolean, default: false },
|
|
25
|
+
followers_count: { type: Number, default: 0 },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const page = usePage();
|
|
29
|
+
const activeTab = ref('reply');
|
|
30
|
+
const showShortcutHelp = ref(false);
|
|
31
|
+
const replyComposerRef = ref(null);
|
|
32
|
+
const statusSelectRef = ref(null);
|
|
33
|
+
const prioritySelectRef = ref(null);
|
|
34
|
+
|
|
35
|
+
const statusForm = useForm({ status: '' });
|
|
36
|
+
const priorityForm = useForm({ priority: '' });
|
|
37
|
+
const assignForm = useForm({ agent_id: '' });
|
|
38
|
+
|
|
39
|
+
function changeStatus(status) {
|
|
40
|
+
statusForm.status = status;
|
|
41
|
+
statusForm.post(route('escalated.agent.tickets.status', props.ticket.reference), { preserveScroll: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function changePriority(priority) {
|
|
45
|
+
priorityForm.priority = priority;
|
|
46
|
+
priorityForm.post(route('escalated.agent.tickets.priority', props.ticket.reference), { preserveScroll: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assignToMe() {
|
|
50
|
+
assignForm.agent_id = page.props.auth.user.id;
|
|
51
|
+
assignForm.post(route('escalated.agent.tickets.assign', props.ticket.reference), { preserveScroll: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toggleFollow() {
|
|
55
|
+
router.post(route('escalated.agent.tickets.follow', props.ticket.reference), {}, { preserveScroll: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Keyboard shortcuts
|
|
59
|
+
useKeyboardShortcuts({
|
|
60
|
+
'r': () => { activeTab.value = 'reply'; replyComposerRef.value?.$el?.querySelector('textarea')?.focus(); },
|
|
61
|
+
'n': () => { activeTab.value = 'note'; },
|
|
62
|
+
's': () => { statusSelectRef.value?.focus(); },
|
|
63
|
+
'p': () => { prioritySelectRef.value?.focus(); },
|
|
64
|
+
'f': () => { toggleFollow(); },
|
|
65
|
+
'?': () => { showShortcutHelp.value = true; },
|
|
66
|
+
});
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<EscalatedLayout :title="ticket.subject">
|
|
71
|
+
<div class="mb-5 flex flex-wrap items-center gap-3">
|
|
72
|
+
<span class="text-sm font-mono font-medium text-white">{{ ticket.reference }}</span>
|
|
73
|
+
<StatusBadge :status="ticket.status" />
|
|
74
|
+
<PriorityBadge :priority="ticket.priority" />
|
|
75
|
+
<span class="text-sm text-neutral-500">by {{ ticket.requester?.name }}</span>
|
|
76
|
+
<PresenceIndicator :ticket-reference="ticket.reference" route-prefix="escalated.agent" />
|
|
77
|
+
<div class="ml-auto flex items-center gap-2">
|
|
78
|
+
<FollowButton :is-following="is_following" :followers-count="followers_count"
|
|
79
|
+
:action="route('escalated.agent.tickets.follow', ticket.reference)" />
|
|
80
|
+
<MacroDropdown v-if="macros.length" :macros="macros"
|
|
81
|
+
:action="route('escalated.agent.tickets.macro', ticket.reference)" />
|
|
82
|
+
<button v-if="!ticket.assigned_to" @click="assignToMe"
|
|
83
|
+
class="rounded-lg bg-gradient-to-r from-cyan-500 to-violet-500 px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-black/20 transition-all hover:from-cyan-400 hover:to-violet-400">
|
|
84
|
+
Assign to Me
|
|
85
|
+
</button>
|
|
86
|
+
<select ref="statusSelectRef" @change="changeStatus($event.target.value); $event.target.value = ''"
|
|
87
|
+
class="rounded-lg border border-white/10 bg-neutral-950 px-3 py-1.5 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10">
|
|
88
|
+
<option value="">Change Status...</option>
|
|
89
|
+
<option value="in_progress">In Progress</option>
|
|
90
|
+
<option value="waiting_on_customer">Waiting on Customer</option>
|
|
91
|
+
<option value="resolved">Resolved</option>
|
|
92
|
+
<option value="closed">Closed</option>
|
|
93
|
+
</select>
|
|
94
|
+
<select ref="prioritySelectRef" @change="changePriority($event.target.value); $event.target.value = ''"
|
|
95
|
+
class="rounded-lg border border-white/10 bg-neutral-950 px-3 py-1.5 text-sm text-neutral-200 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10">
|
|
96
|
+
<option value="">Change Priority...</option>
|
|
97
|
+
<option value="low">Low</option>
|
|
98
|
+
<option value="medium">Medium</option>
|
|
99
|
+
<option value="high">High</option>
|
|
100
|
+
<option value="urgent">Urgent</option>
|
|
101
|
+
<option value="critical">Critical</option>
|
|
102
|
+
</select>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
106
|
+
<div class="lg:col-span-2 space-y-6">
|
|
107
|
+
<PinnedNotes v-if="ticket.pinned_notes?.length" :notes="ticket.pinned_notes"
|
|
108
|
+
:ticket-reference="ticket.reference" route-prefix="escalated.agent" />
|
|
109
|
+
<div class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-5">
|
|
110
|
+
<p class="whitespace-pre-wrap text-sm text-neutral-300">{{ ticket.description }}</p>
|
|
111
|
+
<AttachmentList v-if="ticket.attachments?.length" :attachments="ticket.attachments" class="mt-3" />
|
|
112
|
+
</div>
|
|
113
|
+
<div>
|
|
114
|
+
<div class="mb-4 flex gap-4 border-b border-white/[0.06]">
|
|
115
|
+
<button @click="activeTab = 'reply'"
|
|
116
|
+
:class="['pb-2 text-sm font-medium transition-colors', activeTab === 'reply' ? 'border-b-2 border-cyan-500 text-white' : 'text-neutral-500 hover:text-neutral-300']">
|
|
117
|
+
Reply
|
|
118
|
+
</button>
|
|
119
|
+
<button @click="activeTab = 'note'"
|
|
120
|
+
:class="['pb-2 text-sm font-medium transition-colors', activeTab === 'note' ? 'border-b-2 border-amber-500 text-amber-400' : 'text-neutral-500 hover:text-neutral-300']">
|
|
121
|
+
Internal Note
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
<ReplyComposer v-if="activeTab === 'reply'" ref="replyComposerRef"
|
|
125
|
+
:action="route('escalated.agent.tickets.reply', ticket.reference)"
|
|
126
|
+
:canned-responses="cannedResponses" />
|
|
127
|
+
<ReplyComposer v-else
|
|
128
|
+
:action="route('escalated.agent.tickets.note', ticket.reference)"
|
|
129
|
+
placeholder="Write an internal note..."
|
|
130
|
+
submit-label="Add Note" />
|
|
131
|
+
</div>
|
|
132
|
+
<div>
|
|
133
|
+
<h2 class="mb-4 text-lg font-semibold text-neutral-200">Conversation</h2>
|
|
134
|
+
<ReplyThread :replies="ticket.replies || []" :current-user-id="page.props.auth?.user?.id"
|
|
135
|
+
:ticket-reference="ticket.reference" route-prefix="escalated.agent" pinnable />
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div>
|
|
139
|
+
<TicketSidebar :ticket="ticket" :tags="tags" :departments="departments" />
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<KeyboardShortcutHelp v-model:show="showShortcutHelp" context="detail" />
|
|
143
|
+
</EscalatedLayout>
|
|
144
|
+
</template>
|
|
@@ -1,55 +1,63 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
-
import StatusBadge from '../../components/StatusBadge.vue';
|
|
4
|
-
import PriorityBadge from '../../components/PriorityBadge.vue';
|
|
5
|
-
import ReplyThread from '../../components/ReplyThread.vue';
|
|
6
|
-
import ReplyComposer from '../../components/ReplyComposer.vue';
|
|
7
|
-
import AttachmentList from '../../components/AttachmentList.vue';
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<span
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<div
|
|
51
|
-
<
|
|
52
|
-
<
|
|
53
|
-
</div>
|
|
54
|
-
|
|
55
|
-
</
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
+
import StatusBadge from '../../components/StatusBadge.vue';
|
|
4
|
+
import PriorityBadge from '../../components/PriorityBadge.vue';
|
|
5
|
+
import ReplyThread from '../../components/ReplyThread.vue';
|
|
6
|
+
import ReplyComposer from '../../components/ReplyComposer.vue';
|
|
7
|
+
import AttachmentList from '../../components/AttachmentList.vue';
|
|
8
|
+
import SatisfactionRating from '../../components/SatisfactionRating.vue';
|
|
9
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
10
|
+
|
|
11
|
+
const props = defineProps({ ticket: Object });
|
|
12
|
+
const page = usePage();
|
|
13
|
+
|
|
14
|
+
const isResolved = ['resolved', 'closed'].includes(props.ticket.status);
|
|
15
|
+
|
|
16
|
+
function closeTicket() {
|
|
17
|
+
router.post(route('escalated.customer.tickets.close', props.ticket.reference));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function reopenTicket() {
|
|
21
|
+
router.post(route('escalated.customer.tickets.reopen', props.ticket.reference));
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<EscalatedLayout :title="ticket.subject">
|
|
27
|
+
<div class="mb-4 flex flex-wrap items-center gap-3">
|
|
28
|
+
<span class="text-sm font-medium text-neutral-500">{{ ticket.reference }}</span>
|
|
29
|
+
<StatusBadge :status="ticket.status" />
|
|
30
|
+
<PriorityBadge :priority="ticket.priority" />
|
|
31
|
+
<span v-if="ticket.department" class="text-sm text-neutral-500">{{ ticket.department.name }}</span>
|
|
32
|
+
<div class="ml-auto flex gap-2">
|
|
33
|
+
<button v-if="ticket.status === 'resolved' || ticket.status === 'closed'"
|
|
34
|
+
@click="reopenTicket"
|
|
35
|
+
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
|
36
|
+
Reopen
|
|
37
|
+
</button>
|
|
38
|
+
<button v-if="ticket.status !== 'closed' && ticket.status !== 'resolved'"
|
|
39
|
+
@click="closeTicket"
|
|
40
|
+
class="rounded-lg border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50">
|
|
41
|
+
Close Ticket
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<!-- CSAT Rating -->
|
|
47
|
+
<SatisfactionRating v-if="isResolved && !ticket.satisfaction_rating"
|
|
48
|
+
:action="route('escalated.customer.tickets.rate', ticket.reference)" />
|
|
49
|
+
|
|
50
|
+
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4">
|
|
51
|
+
<p class="whitespace-pre-wrap text-sm text-gray-700">{{ ticket.description }}</p>
|
|
52
|
+
<AttachmentList v-if="ticket.attachments?.length" :attachments="ticket.attachments" class="mt-3" />
|
|
53
|
+
</div>
|
|
54
|
+
<div class="mb-6">
|
|
55
|
+
<h2 class="mb-4 text-lg font-semibold text-gray-900">Replies</h2>
|
|
56
|
+
<ReplyThread :replies="ticket.replies || []" :current-user-id="page.props.auth?.user?.id" />
|
|
57
|
+
</div>
|
|
58
|
+
<div v-if="ticket.status !== 'closed'" class="rounded-lg border border-gray-200 bg-white p-4">
|
|
59
|
+
<h2 class="mb-4 text-lg font-semibold text-gray-900">Reply</h2>
|
|
60
|
+
<ReplyComposer :action="route('escalated.customer.tickets.reply', ticket.reference)" />
|
|
61
|
+
</div>
|
|
62
|
+
</EscalatedLayout>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
+
import FileDropzone from '../../components/FileDropzone.vue';
|
|
4
|
+
import { useForm, Link } from '@inertiajs/vue3';
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
departments: Array,
|
|
8
|
+
priorities: Array,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const form = useForm({
|
|
12
|
+
guest_name: '',
|
|
13
|
+
guest_email: '',
|
|
14
|
+
subject: '',
|
|
15
|
+
description: '',
|
|
16
|
+
priority: 'medium',
|
|
17
|
+
department_id: '',
|
|
18
|
+
attachments: [],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function submit() {
|
|
22
|
+
form.post(route('escalated.guest.tickets.store'));
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<EscalatedLayout title="Submit a Ticket">
|
|
28
|
+
<div class="mx-auto max-w-2xl">
|
|
29
|
+
<div class="mb-6 text-center">
|
|
30
|
+
<h1 class="text-xl font-semibold text-gray-900">Submit a Support Ticket</h1>
|
|
31
|
+
<p class="mt-1 text-sm text-gray-500">No account needed. We'll give you a link to track your ticket.</p>
|
|
32
|
+
<Link :href="route('login')" class="mt-2 inline-block text-sm text-indigo-600 hover:text-indigo-700">
|
|
33
|
+
Already have an account? Sign in
|
|
34
|
+
</Link>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<form @submit.prevent="submit" class="space-y-5 rounded-lg border border-gray-200 bg-white p-6">
|
|
38
|
+
<div class="grid grid-cols-2 gap-4">
|
|
39
|
+
<div>
|
|
40
|
+
<label class="block text-sm font-medium text-gray-700">Your Name</label>
|
|
41
|
+
<input v-model="form.guest_name" type="text" required
|
|
42
|
+
class="mt-1 w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
43
|
+
<div v-if="form.errors.guest_name" class="mt-1 text-sm text-red-600">{{ form.errors.guest_name }}</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div>
|
|
46
|
+
<label class="block text-sm font-medium text-gray-700">Email Address</label>
|
|
47
|
+
<input v-model="form.guest_email" type="email" required
|
|
48
|
+
class="mt-1 w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
49
|
+
<div v-if="form.errors.guest_email" class="mt-1 text-sm text-red-600">{{ form.errors.guest_email }}</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div>
|
|
54
|
+
<label class="block text-sm font-medium text-gray-700">Subject</label>
|
|
55
|
+
<input v-model="form.subject" type="text" required
|
|
56
|
+
class="mt-1 w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
|
57
|
+
<div v-if="form.errors.subject" class="mt-1 text-sm text-red-600">{{ form.errors.subject }}</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div>
|
|
61
|
+
<label class="block text-sm font-medium text-gray-700">Description</label>
|
|
62
|
+
<textarea v-model="form.description" rows="6" required
|
|
63
|
+
class="mt-1 w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"></textarea>
|
|
64
|
+
<div v-if="form.errors.description" class="mt-1 text-sm text-red-600">{{ form.errors.description }}</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="grid grid-cols-2 gap-4">
|
|
68
|
+
<div>
|
|
69
|
+
<label class="block text-sm font-medium text-gray-700">Priority</label>
|
|
70
|
+
<select v-model="form.priority" class="mt-1 w-full rounded-lg border-gray-300 shadow-sm">
|
|
71
|
+
<option v-for="p in priorities" :key="p" :value="p" class="capitalize">{{ p }}</option>
|
|
72
|
+
</select>
|
|
73
|
+
</div>
|
|
74
|
+
<div>
|
|
75
|
+
<label class="block text-sm font-medium text-gray-700">Department</label>
|
|
76
|
+
<select v-model="form.department_id" class="mt-1 w-full rounded-lg border-gray-300 shadow-sm">
|
|
77
|
+
<option value="">General</option>
|
|
78
|
+
<option v-for="d in departments" :key="d.id" :value="d.id">{{ d.name }}</option>
|
|
79
|
+
</select>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div>
|
|
84
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Attachments</label>
|
|
85
|
+
<FileDropzone v-model="form.attachments" />
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="flex justify-end">
|
|
89
|
+
<button type="submit" :disabled="form.processing"
|
|
90
|
+
class="rounded-lg bg-indigo-600 px-6 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50">
|
|
91
|
+
{{ form.processing ? 'Submitting...' : 'Submit Ticket' }}
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
</form>
|
|
95
|
+
</div>
|
|
96
|
+
</EscalatedLayout>
|
|
97
|
+
</template>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
+
import StatusBadge from '../../components/StatusBadge.vue';
|
|
4
|
+
import PriorityBadge from '../../components/PriorityBadge.vue';
|
|
5
|
+
import ReplyThread from '../../components/ReplyThread.vue';
|
|
6
|
+
import AttachmentList from '../../components/AttachmentList.vue';
|
|
7
|
+
import SatisfactionRating from '../../components/SatisfactionRating.vue';
|
|
8
|
+
import { useForm } from '@inertiajs/vue3';
|
|
9
|
+
import { ref } from 'vue';
|
|
10
|
+
|
|
11
|
+
const props = defineProps({ ticket: Object, token: String });
|
|
12
|
+
|
|
13
|
+
const isResolved = ['resolved', 'closed'].includes(props.ticket.status);
|
|
14
|
+
|
|
15
|
+
const replyForm = useForm({ body: '', attachments: [] });
|
|
16
|
+
const showCopied = ref(false);
|
|
17
|
+
|
|
18
|
+
function submitReply() {
|
|
19
|
+
replyForm.post(route('escalated.guest.tickets.reply', props.token), {
|
|
20
|
+
onSuccess: () => replyForm.reset(),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function copyLink() {
|
|
25
|
+
navigator.clipboard.writeText(window.location.href);
|
|
26
|
+
showCopied.value = true;
|
|
27
|
+
setTimeout(() => showCopied.value = false, 2000);
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<EscalatedLayout :title="ticket.subject">
|
|
33
|
+
<div class="mx-auto max-w-3xl">
|
|
34
|
+
<!-- Bookmark notice -->
|
|
35
|
+
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
|
|
36
|
+
<div class="flex items-start gap-3">
|
|
37
|
+
<svg class="mt-0.5 h-5 w-5 shrink-0 text-amber-500" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /></svg>
|
|
38
|
+
<div>
|
|
39
|
+
<p class="text-sm font-medium text-amber-800">Bookmark this page</p>
|
|
40
|
+
<p class="mt-0.5 text-xs text-amber-600">This is your private link to view and reply to this ticket.</p>
|
|
41
|
+
</div>
|
|
42
|
+
<button @click="copyLink"
|
|
43
|
+
class="ml-auto shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50">
|
|
44
|
+
{{ showCopied ? 'Copied!' : 'Copy Link' }}
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<!-- Ticket header -->
|
|
50
|
+
<div class="mb-4 flex flex-wrap items-center gap-3">
|
|
51
|
+
<span class="font-mono text-sm text-gray-500">{{ ticket.reference }}</span>
|
|
52
|
+
<StatusBadge :status="ticket.status" />
|
|
53
|
+
<PriorityBadge :priority="ticket.priority" />
|
|
54
|
+
<span v-if="ticket.department" class="text-sm text-gray-500">{{ ticket.department.name }}</span>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<!-- CSAT Rating -->
|
|
58
|
+
<SatisfactionRating v-if="isResolved && !ticket.satisfaction_rating"
|
|
59
|
+
:action="route('escalated.guest.tickets.rate', token)" />
|
|
60
|
+
|
|
61
|
+
<!-- Description -->
|
|
62
|
+
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4">
|
|
63
|
+
<p class="whitespace-pre-wrap text-sm text-gray-700">{{ ticket.description }}</p>
|
|
64
|
+
<AttachmentList v-if="ticket.attachments?.length" :attachments="ticket.attachments" class="mt-3" />
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<!-- Replies -->
|
|
68
|
+
<div class="mb-6">
|
|
69
|
+
<h2 class="mb-4 text-lg font-semibold text-gray-900">Replies</h2>
|
|
70
|
+
<ReplyThread :replies="ticket.replies || []" />
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Reply form -->
|
|
74
|
+
<div v-if="ticket.status !== 'closed'" class="rounded-lg border border-gray-200 bg-white p-4">
|
|
75
|
+
<h2 class="mb-4 text-lg font-semibold text-gray-900">Reply</h2>
|
|
76
|
+
<form @submit.prevent="submitReply">
|
|
77
|
+
<textarea v-model="replyForm.body" rows="4" required placeholder="Write your reply..."
|
|
78
|
+
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"></textarea>
|
|
79
|
+
<div v-if="replyForm.errors.body" class="mt-1 text-sm text-red-600">{{ replyForm.errors.body }}</div>
|
|
80
|
+
<div class="mt-3 flex justify-end">
|
|
81
|
+
<button type="submit" :disabled="replyForm.processing"
|
|
82
|
+
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50">
|
|
83
|
+
{{ replyForm.processing ? 'Sending...' : 'Send Reply' }}
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</form>
|
|
87
|
+
</div>
|
|
88
|
+
<div v-else class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-6 text-center text-sm text-gray-500">
|
|
89
|
+
This ticket is closed. No further replies can be added.
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</EscalatedLayout>
|
|
93
|
+
</template>
|