@escalated-dev/escalated 0.2.1
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/LICENSE +21 -0
- package/README.md +190 -0
- package/package.json +37 -0
- package/src/components/ActivityTimeline.vue +62 -0
- package/src/components/AssigneeSelect.vue +19 -0
- package/src/components/AttachmentList.vue +31 -0
- package/src/components/EscalatedLayout.vue +213 -0
- package/src/components/FileDropzone.vue +36 -0
- package/src/components/PriorityBadge.vue +21 -0
- package/src/components/ReplyComposer.vue +110 -0
- package/src/components/ReplyThread.vue +30 -0
- package/src/components/SlaTimer.vue +43 -0
- package/src/components/StatsCard.vue +26 -0
- package/src/components/StatusBadge.vue +24 -0
- package/src/components/TagSelect.vue +49 -0
- package/src/components/TicketFilters.vue +52 -0
- package/src/components/TicketList.vue +51 -0
- package/src/components/TicketSidebar.vue +70 -0
- package/src/index.js +19 -0
- package/src/pages/Admin/CannedResponses/Index.vue +58 -0
- package/src/pages/Admin/Departments/Form.vue +50 -0
- package/src/pages/Admin/Departments/Index.vue +49 -0
- package/src/pages/Admin/EscalationRules/Form.vue +95 -0
- package/src/pages/Admin/EscalationRules/Index.vue +49 -0
- package/src/pages/Admin/Reports.vue +52 -0
- package/src/pages/Admin/SlaPolicies/Form.vue +74 -0
- package/src/pages/Admin/SlaPolicies/Index.vue +51 -0
- package/src/pages/Admin/Tags/Index.vue +80 -0
- package/src/pages/Agent/Dashboard.vue +51 -0
- package/src/pages/Agent/TicketIndex.vue +21 -0
- package/src/pages/Agent/TicketShow.vue +108 -0
- package/src/pages/Customer/Create.vue +66 -0
- package/src/pages/Customer/Index.vue +24 -0
- package/src/pages/Customer/Show.vue +55 -0
- package/src/plugin.js +65 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue';
|
|
3
|
+
import { router } from '@inertiajs/vue3';
|
|
4
|
+
import FileDropzone from './FileDropzone.vue';
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
action: { type: String, required: true },
|
|
8
|
+
cannedResponses: { type: Array, default: () => [] },
|
|
9
|
+
allowNotes: { type: Boolean, default: false },
|
|
10
|
+
placeholder: { type: String, default: 'Type your reply...' },
|
|
11
|
+
submitLabel: { type: String, default: null },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits(['submit']);
|
|
15
|
+
|
|
16
|
+
const body = ref('');
|
|
17
|
+
const isNote = ref(false);
|
|
18
|
+
const files = ref([]);
|
|
19
|
+
const submitting = ref(false);
|
|
20
|
+
|
|
21
|
+
function insertCanned(response) {
|
|
22
|
+
body.value += response.body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function handleFiles(newFiles) {
|
|
26
|
+
files.value = [...files.value, ...newFiles];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function removeFile(index) {
|
|
30
|
+
files.value.splice(index, 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function submit() {
|
|
34
|
+
if (!body.value.trim() || submitting.value) return;
|
|
35
|
+
submitting.value = true;
|
|
36
|
+
|
|
37
|
+
const payload = {
|
|
38
|
+
body: body.value,
|
|
39
|
+
is_internal_note: isNote.value,
|
|
40
|
+
attachments: files.value,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
emit('submit', payload);
|
|
44
|
+
|
|
45
|
+
router.post(props.action, payload, {
|
|
46
|
+
preserveScroll: true,
|
|
47
|
+
onSuccess: () => {
|
|
48
|
+
body.value = '';
|
|
49
|
+
files.value = [];
|
|
50
|
+
isNote.value = false;
|
|
51
|
+
},
|
|
52
|
+
onFinish: () => {
|
|
53
|
+
submitting.value = false;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const buttonLabel = computed(() => {
|
|
59
|
+
if (props.submitLabel) return props.submitLabel;
|
|
60
|
+
return isNote.value ? 'Add Note' : 'Send Reply';
|
|
61
|
+
});
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
|
66
|
+
<div v-if="allowNotes" class="mb-3 flex gap-2">
|
|
67
|
+
<button @click="isNote = false"
|
|
68
|
+
: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']">
|
|
69
|
+
Reply
|
|
70
|
+
</button>
|
|
71
|
+
<button @click="isNote = true"
|
|
72
|
+
:class="['rounded-md px-3 py-1 text-sm font-medium', isNote ? 'bg-yellow-100 text-yellow-700' : 'text-gray-500 hover:bg-gray-100']">
|
|
73
|
+
Internal Note
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div v-if="isNote" class="mb-2 rounded bg-yellow-50 px-3 py-1.5 text-xs text-yellow-700">
|
|
78
|
+
This note is only visible to agents.
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<textarea v-model="body" rows="4" :placeholder="placeholder"
|
|
82
|
+
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
|
|
83
|
+
|
|
84
|
+
<FileDropzone @files="handleFiles" class="mt-2" />
|
|
85
|
+
|
|
86
|
+
<div v-if="files.length" class="mt-2 space-y-1">
|
|
87
|
+
<div v-for="(file, i) in files" :key="i" class="flex items-center gap-2 text-sm text-gray-600">
|
|
88
|
+
<span>{{ file.name }}</span>
|
|
89
|
+
<button @click="removeFile(i)" class="text-red-500 hover:text-red-700">×</button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div class="mt-3 flex items-center justify-between">
|
|
94
|
+
<div v-if="cannedResponses.length" class="relative">
|
|
95
|
+
<select @change="insertCanned(cannedResponses[$event.target.value]); $event.target.value = ''"
|
|
96
|
+
class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-600">
|
|
97
|
+
<option value="">Canned responses...</option>
|
|
98
|
+
<option v-for="(cr, i) in cannedResponses" :key="cr.id" :value="i">{{ cr.title }}</option>
|
|
99
|
+
</select>
|
|
100
|
+
</div>
|
|
101
|
+
<div v-else></div>
|
|
102
|
+
<button @click="submit" :disabled="!body.trim() || submitting"
|
|
103
|
+
:class="['rounded-md px-4 py-2 text-sm font-medium text-white',
|
|
104
|
+
isNote ? 'bg-yellow-500 hover:bg-yellow-600' : 'bg-blue-600 hover:bg-blue-700',
|
|
105
|
+
(!body.trim() || submitting) && 'cursor-not-allowed opacity-50']">
|
|
106
|
+
{{ buttonLabel }}
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
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>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
dueAt: { type: String, default: null },
|
|
6
|
+
breached: { type: Boolean, default: false },
|
|
7
|
+
label: { type: String, default: 'Due' },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const timeRemaining = computed(() => {
|
|
11
|
+
if (!props.dueAt) return null;
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const due = new Date(props.dueAt);
|
|
14
|
+
const diff = due - now;
|
|
15
|
+
if (diff <= 0) return { text: 'Overdue', overdue: true };
|
|
16
|
+
|
|
17
|
+
const hours = Math.floor(diff / 3600000);
|
|
18
|
+
const minutes = Math.floor((diff % 3600000) / 60000);
|
|
19
|
+
if (hours > 24) {
|
|
20
|
+
const days = Math.floor(hours / 24);
|
|
21
|
+
return { text: `${days}d ${hours % 24}h`, overdue: false };
|
|
22
|
+
}
|
|
23
|
+
return { text: `${hours}h ${minutes}m`, overdue: false };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const statusClass = computed(() => {
|
|
27
|
+
if (props.breached || timeRemaining.value?.overdue) return 'border-red-300 bg-red-50 text-red-700';
|
|
28
|
+
const due = new Date(props.dueAt);
|
|
29
|
+
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';
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div v-if="dueAt" :class="['rounded-md border px-3 py-2 text-sm', statusClass]">
|
|
37
|
+
<div class="font-medium">{{ label }}</div>
|
|
38
|
+
<div class="text-xs">
|
|
39
|
+
<span v-if="breached">⚠ SLA Breached</span>
|
|
40
|
+
<span v-else>{{ timeRemaining?.text }}</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
label: { type: String, required: true },
|
|
4
|
+
value: { type: [Number, String], required: true },
|
|
5
|
+
trend: { type: String, default: null },
|
|
6
|
+
color: { type: String, default: 'blue' },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const colorMap = {
|
|
10
|
+
blue: 'bg-blue-50 text-blue-700',
|
|
11
|
+
green: 'bg-green-50 text-green-700',
|
|
12
|
+
red: 'bg-red-50 text-red-700',
|
|
13
|
+
yellow: 'bg-yellow-50 text-yellow-700',
|
|
14
|
+
gray: 'bg-gray-50 text-gray-700',
|
|
15
|
+
};
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
|
20
|
+
<div class="text-sm text-gray-500">{{ label }}</div>
|
|
21
|
+
<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]">
|
|
23
|
+
{{ trend }}
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
status: { type: String, required: true },
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
const statusConfig = {
|
|
7
|
+
open: { label: 'Open', color: 'bg-blue-100 text-blue-800' },
|
|
8
|
+
in_progress: { label: 'In Progress', color: 'bg-purple-100 text-purple-800' },
|
|
9
|
+
waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-yellow-100 text-yellow-800' },
|
|
10
|
+
waiting_on_agent: { label: 'Waiting on Agent', color: 'bg-orange-100 text-orange-800' },
|
|
11
|
+
escalated: { label: 'Escalated', color: 'bg-red-100 text-red-800' },
|
|
12
|
+
resolved: { label: 'Resolved', color: 'bg-green-100 text-green-800' },
|
|
13
|
+
closed: { label: 'Closed', color: 'bg-gray-100 text-gray-800' },
|
|
14
|
+
reopened: { label: 'Reopened', color: 'bg-blue-100 text-blue-800' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const config = statusConfig[props.status] || { label: props.status, color: 'bg-gray-100 text-gray-800' };
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<span :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]">
|
|
22
|
+
{{ config.label }}
|
|
23
|
+
</span>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
tags: { type: Array, required: true },
|
|
6
|
+
modelValue: { type: Array, default: () => [] },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits(['update:modelValue']);
|
|
10
|
+
|
|
11
|
+
const search = ref('');
|
|
12
|
+
|
|
13
|
+
const filteredTags = computed(() => {
|
|
14
|
+
if (!search.value) return props.tags;
|
|
15
|
+
const q = search.value.toLowerCase();
|
|
16
|
+
return props.tags.filter(t => t.name.toLowerCase().includes(q));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function toggle(tagId) {
|
|
20
|
+
const current = [...props.modelValue];
|
|
21
|
+
const index = current.indexOf(tagId);
|
|
22
|
+
if (index >= 0) {
|
|
23
|
+
current.splice(index, 1);
|
|
24
|
+
} else {
|
|
25
|
+
current.push(tagId);
|
|
26
|
+
}
|
|
27
|
+
emit('update:modelValue', current);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isSelected(tagId) {
|
|
31
|
+
return props.modelValue.includes(tagId);
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div>
|
|
37
|
+
<label class="mb-1 block text-xs font-medium text-gray-600">Tags</label>
|
|
38
|
+
<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
|
+
<div class="flex flex-wrap gap-1">
|
|
41
|
+
<button v-for="tag in filteredTags" :key="tag.id" @click="toggle(tag.id)"
|
|
42
|
+
: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']"
|
|
44
|
+
:style="tag.color ? { backgroundColor: isSelected(tag.id) ? tag.color + '33' : undefined, color: isSelected(tag.id) ? tag.color : undefined } : {}">
|
|
45
|
+
{{ tag.name }}
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,52 @@
|
|
|
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>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import StatusBadge from './StatusBadge.vue';
|
|
3
|
+
import PriorityBadge from './PriorityBadge.vue';
|
|
4
|
+
import { Link } from '@inertiajs/vue3';
|
|
5
|
+
|
|
6
|
+
defineProps({
|
|
7
|
+
tickets: { type: Object, required: true },
|
|
8
|
+
routePrefix: { type: String, default: 'escalated.customer.tickets' },
|
|
9
|
+
showAssignee: { type: Boolean, default: false },
|
|
10
|
+
});
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
|
15
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
16
|
+
<thead class="bg-gray-50">
|
|
17
|
+
<tr>
|
|
18
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Reference</th>
|
|
19
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Subject</th>
|
|
20
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Status</th>
|
|
21
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
|
|
22
|
+
<th v-if="showAssignee" class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Assignee</th>
|
|
23
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Created</th>
|
|
24
|
+
</tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody class="divide-y divide-gray-200">
|
|
27
|
+
<tr v-for="ticket in tickets.data" :key="ticket.id" class="hover:bg-gray-50">
|
|
28
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900">
|
|
29
|
+
<Link :href="route(`.show`, ticket.reference)" class="text-indigo-600 hover:text-indigo-900">
|
|
30
|
+
{{ ticket.reference }}
|
|
31
|
+
</Link>
|
|
32
|
+
</td>
|
|
33
|
+
<td class="px-4 py-3 text-sm text-gray-900">{{ ticket.subject }}</td>
|
|
34
|
+
<td class="px-4 py-3"><StatusBadge :status="ticket.status" /></td>
|
|
35
|
+
<td class="px-4 py-3"><PriorityBadge :priority="ticket.priority" /></td>
|
|
36
|
+
<td v-if="showAssignee" class="px-4 py-3 text-sm text-gray-500">
|
|
37
|
+
{{ ticket.assignee?.name || 'Unassigned' }}
|
|
38
|
+
</td>
|
|
39
|
+
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
|
40
|
+
{{ new Date(ticket.created_at).toLocaleDateString() }}
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
<tr v-if="!tickets.data?.length">
|
|
44
|
+
<td :colspan="showAssignee ? 6 : 5" class="px-4 py-8 text-center text-sm text-gray-500">
|
|
45
|
+
No tickets found.
|
|
46
|
+
</td>
|
|
47
|
+
</tr>
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import StatusBadge from './StatusBadge.vue';
|
|
3
|
+
import PriorityBadge from './PriorityBadge.vue';
|
|
4
|
+
import SlaTimer from './SlaTimer.vue';
|
|
5
|
+
import AssigneeSelect from './AssigneeSelect.vue';
|
|
6
|
+
import TagSelect from './TagSelect.vue';
|
|
7
|
+
import ActivityTimeline from './ActivityTimeline.vue';
|
|
8
|
+
|
|
9
|
+
defineProps({
|
|
10
|
+
ticket: { type: Object, required: true },
|
|
11
|
+
agents: { type: Array, default: () => [] },
|
|
12
|
+
tags: { type: Array, default: () => [] },
|
|
13
|
+
activities: { type: Array, default: () => [] },
|
|
14
|
+
editable: { type: Boolean, default: false },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status']);
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<aside class="space-y-4">
|
|
22
|
+
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
|
23
|
+
<h3 class="mb-3 text-sm font-semibold text-gray-900">Details</h3>
|
|
24
|
+
<dl class="space-y-2 text-sm">
|
|
25
|
+
<div class="flex justify-between">
|
|
26
|
+
<dt class="text-gray-500">Status</dt>
|
|
27
|
+
<dd><StatusBadge :status="ticket.status" /></dd>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="flex justify-between">
|
|
30
|
+
<dt class="text-gray-500">Priority</dt>
|
|
31
|
+
<dd><PriorityBadge :priority="ticket.priority" /></dd>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="flex justify-between">
|
|
34
|
+
<dt class="text-gray-500">Reference</dt>
|
|
35
|
+
<dd class="font-mono text-xs">{{ ticket.reference }}</dd>
|
|
36
|
+
</div>
|
|
37
|
+
<div v-if="ticket.department" class="flex justify-between">
|
|
38
|
+
<dt class="text-gray-500">Department</dt>
|
|
39
|
+
<dd>{{ ticket.department.name }}</dd>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="flex justify-between">
|
|
42
|
+
<dt class="text-gray-500">Created</dt>
|
|
43
|
+
<dd>{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
|
|
44
|
+
</div>
|
|
45
|
+
</dl>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div v-if="ticket.first_response_due_at || ticket.resolution_due_at" class="space-y-2">
|
|
49
|
+
<SlaTimer v-if="ticket.first_response_due_at" :due-at="ticket.first_response_due_at"
|
|
50
|
+
:breached="ticket.sla_first_response_breached" label="First Response" />
|
|
51
|
+
<SlaTimer v-if="ticket.resolution_due_at" :due-at="ticket.resolution_due_at"
|
|
52
|
+
:breached="ticket.sla_resolution_breached" label="Resolution" />
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div v-if="editable && agents.length" class="rounded-lg border border-gray-200 bg-white p-4">
|
|
56
|
+
<AssigneeSelect :agents="agents" :model-value="ticket.assigned_to"
|
|
57
|
+
@update:model-value="emit('assign', $event)" />
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div v-if="editable && tags.length" class="rounded-lg border border-gray-200 bg-white p-4">
|
|
61
|
+
<TagSelect :tags="tags" :model-value="(ticket.tags || []).map(t => t.id)"
|
|
62
|
+
@update:model-value="emit('tags', $event)" />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div v-if="activities.length" class="rounded-lg border border-gray-200 bg-white p-4">
|
|
66
|
+
<h3 class="mb-3 text-sm font-semibold text-gray-900">Activity</h3>
|
|
67
|
+
<ActivityTimeline :activities="activities" />
|
|
68
|
+
</div>
|
|
69
|
+
</aside>
|
|
70
|
+
</template>
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Plugin
|
|
2
|
+
export { EscalatedPlugin } from './plugin'
|
|
3
|
+
|
|
4
|
+
// Components
|
|
5
|
+
export { default as ActivityTimeline } from './components/ActivityTimeline.vue'
|
|
6
|
+
export { default as AssigneeSelect } from './components/AssigneeSelect.vue'
|
|
7
|
+
export { default as AttachmentList } from './components/AttachmentList.vue'
|
|
8
|
+
export { default as EscalatedLayout } from './components/EscalatedLayout.vue'
|
|
9
|
+
export { default as FileDropzone } from './components/FileDropzone.vue'
|
|
10
|
+
export { default as PriorityBadge } from './components/PriorityBadge.vue'
|
|
11
|
+
export { default as ReplyComposer } from './components/ReplyComposer.vue'
|
|
12
|
+
export { default as ReplyThread } from './components/ReplyThread.vue'
|
|
13
|
+
export { default as SlaTimer } from './components/SlaTimer.vue'
|
|
14
|
+
export { default as StatsCard } from './components/StatsCard.vue'
|
|
15
|
+
export { default as StatusBadge } from './components/StatusBadge.vue'
|
|
16
|
+
export { default as TagSelect } from './components/TagSelect.vue'
|
|
17
|
+
export { default as TicketFilters } from './components/TicketFilters.vue'
|
|
18
|
+
export { default as TicketList } from './components/TicketList.vue'
|
|
19
|
+
export { default as TicketSidebar } from './components/TicketSidebar.vue'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
+
import { useForm, router } from '@inertiajs/vue3';
|
|
4
|
+
import { ref } from 'vue';
|
|
5
|
+
|
|
6
|
+
defineProps({ responses: Array });
|
|
7
|
+
|
|
8
|
+
const showForm = ref(false);
|
|
9
|
+
const form = useForm({ title: '', body: '', category: '', is_shared: true });
|
|
10
|
+
|
|
11
|
+
function create() {
|
|
12
|
+
form.post(route('escalated.admin.canned-responses.store'), {
|
|
13
|
+
onSuccess: () => { form.reset(); showForm.value = false; },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function destroy(id) {
|
|
18
|
+
if (confirm('Delete this canned response?')) {
|
|
19
|
+
router.delete(route('escalated.admin.canned-responses.destroy', id));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<EscalatedLayout title="Canned Responses">
|
|
26
|
+
<div class="mb-4 flex justify-end">
|
|
27
|
+
<button @click="showForm = !showForm" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
|
|
28
|
+
{{ showForm ? 'Cancel' : 'Add Response' }}
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
<form v-if="showForm" @submit.prevent="create" class="mb-6 space-y-3 rounded-lg border border-gray-200 bg-white p-4">
|
|
32
|
+
<input v-model="form.title" type="text" placeholder="Title" required class="w-full rounded-lg border-gray-300 shadow-sm" />
|
|
33
|
+
<textarea v-model="form.body" rows="4" placeholder="Response body..." required class="w-full rounded-lg border-gray-300 shadow-sm"></textarea>
|
|
34
|
+
<div class="flex gap-3">
|
|
35
|
+
<input v-model="form.category" type="text" placeholder="Category (optional)" class="rounded-lg border-gray-300 shadow-sm" />
|
|
36
|
+
<label class="flex items-center gap-2">
|
|
37
|
+
<input v-model="form.is_shared" type="checkbox" class="rounded border-gray-300" />
|
|
38
|
+
<span class="text-sm text-gray-700">Shared</span>
|
|
39
|
+
</label>
|
|
40
|
+
<button type="submit" :disabled="form.processing" class="ml-auto rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">Create</button>
|
|
41
|
+
</div>
|
|
42
|
+
</form>
|
|
43
|
+
<div class="space-y-3">
|
|
44
|
+
<div v-for="resp in responses" :key="resp.id" class="rounded-lg border border-gray-200 bg-white p-4">
|
|
45
|
+
<div class="mb-2 flex items-center justify-between">
|
|
46
|
+
<div>
|
|
47
|
+
<span class="font-medium text-gray-900">{{ resp.title }}</span>
|
|
48
|
+
<span v-if="resp.category" class="ml-2 rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{{ resp.category }}</span>
|
|
49
|
+
</div>
|
|
50
|
+
<div>
|
|
51
|
+
<button @click="destroy(resp.id)" class="text-sm text-red-600 hover:text-red-900">Delete</button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
<p class="text-sm text-gray-600">{{ resp.body }}</p>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</EscalatedLayout>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
+
import { useForm } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({ department: { type: Object, default: null } });
|
|
6
|
+
|
|
7
|
+
const form = useForm({
|
|
8
|
+
name: props.department?.name || '',
|
|
9
|
+
slug: props.department?.slug || '',
|
|
10
|
+
description: props.department?.description || '',
|
|
11
|
+
is_active: props.department?.is_active ?? true,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function submit() {
|
|
15
|
+
if (props.department) {
|
|
16
|
+
form.put(route('escalated.admin.departments.update', props.department.id));
|
|
17
|
+
} else {
|
|
18
|
+
form.post(route('escalated.admin.departments.store'));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<EscalatedLayout :title="department ? 'Edit Department' : 'New Department'">
|
|
25
|
+
<form @submit.prevent="submit" class="mx-auto max-w-lg space-y-4 rounded-lg border border-gray-200 bg-white p-6">
|
|
26
|
+
<div>
|
|
27
|
+
<label class="block text-sm font-medium text-gray-700">Name</label>
|
|
28
|
+
<input v-model="form.name" type="text" required class="mt-1 w-full rounded-lg border-gray-300 shadow-sm" />
|
|
29
|
+
<div v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div>
|
|
32
|
+
<label class="block text-sm font-medium text-gray-700">Slug</label>
|
|
33
|
+
<input v-model="form.slug" type="text" class="mt-1 w-full rounded-lg border-gray-300 shadow-sm" placeholder="Auto-generated if empty" />
|
|
34
|
+
</div>
|
|
35
|
+
<div>
|
|
36
|
+
<label class="block text-sm font-medium text-gray-700">Description</label>
|
|
37
|
+
<textarea v-model="form.description" rows="3" class="mt-1 w-full rounded-lg border-gray-300 shadow-sm"></textarea>
|
|
38
|
+
</div>
|
|
39
|
+
<label class="flex items-center gap-2">
|
|
40
|
+
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300" />
|
|
41
|
+
<span class="text-sm text-gray-700">Active</span>
|
|
42
|
+
</label>
|
|
43
|
+
<div class="flex justify-end">
|
|
44
|
+
<button type="submit" :disabled="form.processing" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50">
|
|
45
|
+
{{ department ? 'Update' : 'Create' }}
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
</form>
|
|
49
|
+
</EscalatedLayout>
|
|
50
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
|
+
import { Link, router } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
defineProps({ departments: Array });
|
|
6
|
+
|
|
7
|
+
function destroy(id) {
|
|
8
|
+
if (confirm('Delete this department?')) {
|
|
9
|
+
router.delete(route('escalated.admin.departments.destroy', id));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<EscalatedLayout title="Departments">
|
|
16
|
+
<div class="mb-4 flex justify-end">
|
|
17
|
+
<Link :href="route('escalated.admin.departments.create')" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
|
|
18
|
+
Add Department
|
|
19
|
+
</Link>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
|
22
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
23
|
+
<thead class="bg-gray-50">
|
|
24
|
+
<tr>
|
|
25
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Name</th>
|
|
26
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Tickets</th>
|
|
27
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Agents</th>
|
|
28
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Active</th>
|
|
29
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
|
|
30
|
+
</tr>
|
|
31
|
+
</thead>
|
|
32
|
+
<tbody class="divide-y divide-gray-200">
|
|
33
|
+
<tr v-for="dept in departments" :key="dept.id">
|
|
34
|
+
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ dept.name }}</td>
|
|
35
|
+
<td class="px-4 py-3 text-sm text-gray-500">{{ dept.tickets_count }}</td>
|
|
36
|
+
<td class="px-4 py-3 text-sm text-gray-500">{{ dept.agents_count }}</td>
|
|
37
|
+
<td class="px-4 py-3 text-sm">
|
|
38
|
+
<span :class="dept.is_active ? 'text-green-600' : 'text-gray-400'">{{ dept.is_active ? 'Yes' : 'No' }}</span>
|
|
39
|
+
</td>
|
|
40
|
+
<td class="px-4 py-3 text-right text-sm">
|
|
41
|
+
<Link :href="route('escalated.admin.departments.edit', dept.id)" class="text-indigo-600 hover:text-indigo-900">Edit</Link>
|
|
42
|
+
<button @click="destroy(dept.id)" class="ml-3 text-red-600 hover:text-red-900">Delete</button>
|
|
43
|
+
</td>
|
|
44
|
+
</tr>
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
</div>
|
|
48
|
+
</EscalatedLayout>
|
|
49
|
+
</template>
|