@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, inject } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
show: { type: Boolean, default: false },
|
|
6
|
+
context: { type: String, default: 'list' },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits(['update:show']);
|
|
10
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
11
|
+
|
|
12
|
+
const shortcutGroups = [
|
|
13
|
+
{
|
|
14
|
+
title: 'List View',
|
|
15
|
+
contexts: ['list'],
|
|
16
|
+
shortcuts: [
|
|
17
|
+
{ keys: ['j'], description: 'Move down' },
|
|
18
|
+
{ keys: ['k'], description: 'Move up' },
|
|
19
|
+
{ keys: ['x'], description: 'Toggle select' },
|
|
20
|
+
{ keys: ['Enter'], description: 'Open ticket' },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
title: 'Detail View',
|
|
25
|
+
contexts: ['detail'],
|
|
26
|
+
shortcuts: [
|
|
27
|
+
{ keys: ['r'], description: 'Reply' },
|
|
28
|
+
{ keys: ['n'], description: 'Internal note' },
|
|
29
|
+
{ keys: ['s'], description: 'Change status' },
|
|
30
|
+
{ keys: ['p'], description: 'Change priority' },
|
|
31
|
+
{ keys: ['f'], description: 'Follow/unfollow' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
title: 'Global',
|
|
36
|
+
contexts: ['list', 'detail'],
|
|
37
|
+
shortcuts: [
|
|
38
|
+
{ keys: ['?'], description: 'Show this help' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const filteredGroups = computed(() =>
|
|
44
|
+
shortcutGroups.filter(g => g.contexts.includes(props.context))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function closeModal() {
|
|
48
|
+
emit('update:show', false);
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<Teleport to="body">
|
|
54
|
+
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100"
|
|
55
|
+
leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
|
56
|
+
<div v-if="show" class="fixed inset-0 z-[100] flex items-center justify-center" @keydown.escape="closeModal">
|
|
57
|
+
<!-- Backdrop -->
|
|
58
|
+
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="closeModal"></div>
|
|
59
|
+
|
|
60
|
+
<!-- Modal -->
|
|
61
|
+
<div :class="['relative z-10 w-full max-w-lg overflow-hidden rounded-2xl border shadow-2xl',
|
|
62
|
+
escDark ? 'border-white/[0.06] bg-neutral-900' : 'border-gray-200 bg-white']">
|
|
63
|
+
<!-- Header -->
|
|
64
|
+
<div :class="['flex items-center justify-between border-b px-6 py-4',
|
|
65
|
+
escDark ? 'border-white/[0.06]' : 'border-gray-200']">
|
|
66
|
+
<div class="flex items-center gap-3">
|
|
67
|
+
<svg :class="['h-5 w-5', escDark ? 'text-neutral-400' : 'text-gray-500']"
|
|
68
|
+
fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
69
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
|
|
70
|
+
</svg>
|
|
71
|
+
<h2 :class="['text-base font-semibold', escDark ? 'text-white' : 'text-gray-900']">
|
|
72
|
+
Keyboard Shortcuts
|
|
73
|
+
</h2>
|
|
74
|
+
</div>
|
|
75
|
+
<button @click="closeModal"
|
|
76
|
+
:class="['rounded-lg p-1.5 transition-colors',
|
|
77
|
+
escDark ? 'text-neutral-500 hover:bg-white/[0.04] hover:text-neutral-300' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600']">
|
|
78
|
+
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
79
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
80
|
+
</svg>
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- Body -->
|
|
85
|
+
<div class="max-h-[60vh] overflow-y-auto px-6 py-4">
|
|
86
|
+
<div v-for="(group, gi) in filteredGroups" :key="gi"
|
|
87
|
+
:class="['pb-4', gi < filteredGroups.length - 1 && 'mb-4 border-b',
|
|
88
|
+
escDark ? 'border-white/[0.06]' : 'border-gray-100']">
|
|
89
|
+
<h3 :class="['mb-3 text-xs font-semibold uppercase tracking-wider',
|
|
90
|
+
escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
91
|
+
{{ group.title }}
|
|
92
|
+
</h3>
|
|
93
|
+
<div class="space-y-2">
|
|
94
|
+
<div v-for="(shortcut, si) in group.shortcuts" :key="si"
|
|
95
|
+
class="flex items-center justify-between">
|
|
96
|
+
<span :class="['text-sm', escDark ? 'text-neutral-300' : 'text-gray-700']">
|
|
97
|
+
{{ shortcut.description }}
|
|
98
|
+
</span>
|
|
99
|
+
<div class="flex items-center gap-1">
|
|
100
|
+
<kbd v-for="(key, ki) in shortcut.keys" :key="ki"
|
|
101
|
+
:class="['inline-flex min-w-[24px] items-center justify-center rounded-md border px-2 py-0.5 text-xs font-semibold',
|
|
102
|
+
escDark
|
|
103
|
+
? 'border-white/10 bg-white/[0.06] text-neutral-300'
|
|
104
|
+
: 'border-gray-300 bg-gray-100 text-gray-700']">
|
|
105
|
+
{{ key }}
|
|
106
|
+
</kbd>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<!-- Footer -->
|
|
114
|
+
<div :class="['border-t px-6 py-3 text-center text-xs',
|
|
115
|
+
escDark ? 'border-white/[0.06] text-neutral-600' : 'border-gray-100 text-gray-400']">
|
|
116
|
+
Press <kbd :class="['mx-1 inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold',
|
|
117
|
+
escDark ? 'border-white/10 bg-white/[0.06] text-neutral-400' : 'border-gray-300 bg-gray-100 text-gray-600']">Esc</kbd> to close
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</Transition>
|
|
122
|
+
</Teleport>
|
|
123
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject } from 'vue';
|
|
3
|
+
import { router } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
macros: { type: Array, default: () => [] },
|
|
7
|
+
action: { type: String, required: true },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
11
|
+
const open = ref(false);
|
|
12
|
+
const processing = ref(false);
|
|
13
|
+
|
|
14
|
+
function applyMacro(macroId) {
|
|
15
|
+
if (processing.value) return;
|
|
16
|
+
processing.value = true;
|
|
17
|
+
router.post(props.action, { macro_id: macroId }, {
|
|
18
|
+
preserveScroll: true,
|
|
19
|
+
onFinish: () => {
|
|
20
|
+
processing.value = false;
|
|
21
|
+
open.value = false;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toggle() {
|
|
27
|
+
open.value = !open.value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function close() {
|
|
31
|
+
open.value = false;
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div class="relative">
|
|
37
|
+
<!-- Trigger button -->
|
|
38
|
+
<button @click="toggle"
|
|
39
|
+
:class="['inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
|
40
|
+
escDark
|
|
41
|
+
? 'border border-white/10 bg-white/[0.03] text-neutral-300 hover:bg-white/[0.06]'
|
|
42
|
+
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50']"
|
|
43
|
+
:disabled="processing">
|
|
44
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
45
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
|
|
46
|
+
</svg>
|
|
47
|
+
Apply Macro
|
|
48
|
+
<svg :class="['h-4 w-4 transition-transform', open && 'rotate-180']" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
49
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
50
|
+
</svg>
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
<!-- Backdrop -->
|
|
54
|
+
<div v-if="open" class="fixed inset-0 z-30" @click="close"></div>
|
|
55
|
+
|
|
56
|
+
<!-- Dropdown -->
|
|
57
|
+
<Transition enter-active-class="transition duration-100 ease-out" enter-from-class="scale-95 opacity-0" enter-to-class="scale-100 opacity-100"
|
|
58
|
+
leave-active-class="transition duration-75 ease-in" leave-from-class="scale-100 opacity-100" leave-to-class="scale-95 opacity-0">
|
|
59
|
+
<div v-if="open"
|
|
60
|
+
:class="['absolute right-0 z-40 mt-1 w-64 overflow-hidden rounded-xl border shadow-xl',
|
|
61
|
+
escDark ? 'border-white/[0.06] bg-neutral-900' : 'border-gray-200 bg-white']">
|
|
62
|
+
<div :class="['px-3 py-2 text-xs font-semibold uppercase tracking-wider',
|
|
63
|
+
escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
64
|
+
Macros
|
|
65
|
+
</div>
|
|
66
|
+
<div class="max-h-64 overflow-y-auto">
|
|
67
|
+
<div v-if="!macros.length"
|
|
68
|
+
:class="['px-3 py-4 text-center text-sm', escDark ? 'text-neutral-500' : 'text-gray-400']">
|
|
69
|
+
No macros available
|
|
70
|
+
</div>
|
|
71
|
+
<button v-for="macro in macros" :key="macro.id"
|
|
72
|
+
@click="applyMacro(macro.id)"
|
|
73
|
+
:disabled="processing"
|
|
74
|
+
:class="['flex w-full flex-col px-3 py-2.5 text-left transition-colors',
|
|
75
|
+
escDark
|
|
76
|
+
? 'hover:bg-white/[0.04] disabled:opacity-50'
|
|
77
|
+
: 'hover:bg-gray-50 disabled:opacity-50']">
|
|
78
|
+
<span :class="['text-sm font-medium', escDark ? 'text-neutral-200' : 'text-gray-900']">
|
|
79
|
+
{{ macro.name }}
|
|
80
|
+
</span>
|
|
81
|
+
<span v-if="macro.description"
|
|
82
|
+
:class="['mt-0.5 text-xs', escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
83
|
+
{{ macro.description }}
|
|
84
|
+
</span>
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</Transition>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject } from 'vue';
|
|
3
|
+
import { router } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
notes: { type: Array, default: () => [] },
|
|
7
|
+
ticketReference: { type: String, required: true },
|
|
8
|
+
routePrefix: { type: String, required: true },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
12
|
+
const processingId = ref(null);
|
|
13
|
+
|
|
14
|
+
function unpinNote(note) {
|
|
15
|
+
if (processingId.value) return;
|
|
16
|
+
processingId.value = note.id;
|
|
17
|
+
router.post(route(`${props.routePrefix}.tickets.pin`, [props.ticketReference, note.id]), {}, {
|
|
18
|
+
preserveScroll: true,
|
|
19
|
+
onFinish: () => {
|
|
20
|
+
processingId.value = null;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatDate(dateStr) {
|
|
26
|
+
if (!dateStr) return '';
|
|
27
|
+
const date = new Date(dateStr);
|
|
28
|
+
return date.toLocaleDateString(undefined, {
|
|
29
|
+
month: 'short',
|
|
30
|
+
day: 'numeric',
|
|
31
|
+
year: 'numeric',
|
|
32
|
+
hour: 'numeric',
|
|
33
|
+
minute: '2-digit',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div v-if="notes.length > 0"
|
|
40
|
+
:class="['rounded-xl border p-4',
|
|
41
|
+
escDark
|
|
42
|
+
? 'border-amber-500/20 bg-amber-500/[0.06]'
|
|
43
|
+
: 'border-amber-300 bg-amber-50']">
|
|
44
|
+
<!-- Header -->
|
|
45
|
+
<div class="mb-3 flex items-center gap-2">
|
|
46
|
+
<svg :class="['h-4 w-4', escDark ? 'text-amber-400' : 'text-amber-600']"
|
|
47
|
+
fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
|
48
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 3.75V16.5L12 14.25 7.5 16.5V3.75m9 0H18A2.25 2.25 0 0120.25 6v12A2.25 2.25 0 0118 20.25H6A2.25 2.25 0 013.75 18V6A2.25 2.25 0 016 3.75h1.5m9 0h-9" />
|
|
49
|
+
</svg>
|
|
50
|
+
<span :class="['text-xs font-semibold uppercase tracking-wider',
|
|
51
|
+
escDark ? 'text-amber-400' : 'text-amber-700']">
|
|
52
|
+
Pinned Notes
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- Notes list -->
|
|
57
|
+
<div class="space-y-3">
|
|
58
|
+
<div v-for="note in notes" :key="note.id"
|
|
59
|
+
:class="['rounded-lg border p-3',
|
|
60
|
+
escDark
|
|
61
|
+
? 'border-amber-500/10 bg-amber-500/[0.04]'
|
|
62
|
+
: 'border-amber-200 bg-white']">
|
|
63
|
+
<!-- Note body -->
|
|
64
|
+
<div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="note.body"></div>
|
|
65
|
+
|
|
66
|
+
<!-- Meta row -->
|
|
67
|
+
<div class="mt-2 flex items-center justify-between">
|
|
68
|
+
<div :class="['flex items-center gap-2 text-xs',
|
|
69
|
+
escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
70
|
+
<span class="font-medium">{{ note.author?.name || 'Unknown' }}</span>
|
|
71
|
+
<span>·</span>
|
|
72
|
+
<span>{{ formatDate(note.created_at) }}</span>
|
|
73
|
+
</div>
|
|
74
|
+
<button @click="unpinNote(note)"
|
|
75
|
+
:disabled="processingId === note.id"
|
|
76
|
+
:class="['rounded-md px-2 py-1 text-xs font-medium transition-colors',
|
|
77
|
+
escDark
|
|
78
|
+
? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
|
|
79
|
+
: 'text-amber-600 hover:bg-amber-100 hover:text-amber-700',
|
|
80
|
+
processingId === note.id && 'opacity-50 cursor-not-allowed']">
|
|
81
|
+
Unpin
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject, onMounted, onUnmounted } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
ticketReference: { type: String, required: true },
|
|
6
|
+
routePrefix: { type: String, required: true },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
10
|
+
const viewers = ref([]);
|
|
11
|
+
const hovering = ref(false);
|
|
12
|
+
let intervalId = null;
|
|
13
|
+
|
|
14
|
+
async function fetchPresence() {
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(route(`${props.routePrefix}.tickets.presence`, props.ticketReference), {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
'Accept': 'application/json',
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
22
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
if (response.ok) {
|
|
26
|
+
const data = await response.json();
|
|
27
|
+
viewers.value = data.viewers || data || [];
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// silently ignore network errors
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tooltipText = computed(() => {
|
|
35
|
+
if (!viewers.value.length) return '';
|
|
36
|
+
const names = viewers.value.map(v => v.name);
|
|
37
|
+
return 'Also viewing: ' + names.join(', ');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const maxVisible = 4;
|
|
41
|
+
const visibleViewers = computed(() => viewers.value.slice(0, maxVisible));
|
|
42
|
+
const overflowCount = computed(() => Math.max(0, viewers.value.length - maxVisible));
|
|
43
|
+
|
|
44
|
+
function getInitial(viewer) {
|
|
45
|
+
return (viewer.name || '?').charAt(0).toUpperCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const colors = [
|
|
49
|
+
'bg-cyan-500', 'bg-violet-500', 'bg-amber-500', 'bg-emerald-500',
|
|
50
|
+
'bg-rose-500', 'bg-blue-500', 'bg-pink-500', 'bg-teal-500',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function getColor(index) {
|
|
54
|
+
return colors[index % colors.length];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onMounted(() => {
|
|
58
|
+
fetchPresence();
|
|
59
|
+
intervalId = setInterval(fetchPresence, 30000);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
onUnmounted(() => {
|
|
63
|
+
if (intervalId) {
|
|
64
|
+
clearInterval(intervalId);
|
|
65
|
+
intervalId = null;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<div v-if="viewers.length > 0" class="relative" @mouseenter="hovering = true" @mouseleave="hovering = false">
|
|
72
|
+
<!-- Avatar stack -->
|
|
73
|
+
<div class="flex -space-x-2">
|
|
74
|
+
<div v-for="(viewer, index) in visibleViewers" :key="viewer.id || index"
|
|
75
|
+
:class="['flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white ring-2',
|
|
76
|
+
getColor(index),
|
|
77
|
+
escDark ? 'ring-neutral-900' : 'ring-white']">
|
|
78
|
+
{{ getInitial(viewer) }}
|
|
79
|
+
</div>
|
|
80
|
+
<div v-if="overflowCount > 0"
|
|
81
|
+
:class="['flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ring-2',
|
|
82
|
+
escDark
|
|
83
|
+
? 'bg-white/[0.08] text-neutral-300 ring-neutral-900'
|
|
84
|
+
: 'bg-gray-200 text-gray-600 ring-white']">
|
|
85
|
+
+{{ overflowCount }}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Tooltip -->
|
|
90
|
+
<Transition enter-active-class="transition duration-100 ease-out" enter-from-class="opacity-0 translate-y-1" enter-to-class="opacity-100 translate-y-0"
|
|
91
|
+
leave-active-class="transition duration-75 ease-in" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-1">
|
|
92
|
+
<div v-if="hovering"
|
|
93
|
+
:class="['absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium shadow-lg',
|
|
94
|
+
escDark
|
|
95
|
+
? 'border border-white/[0.06] bg-neutral-800 text-neutral-200'
|
|
96
|
+
: 'bg-gray-900 text-white']">
|
|
97
|
+
{{ tooltipText }}
|
|
98
|
+
<div :class="['absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45',
|
|
99
|
+
escDark ? 'bg-neutral-800' : 'bg-gray-900']"></div>
|
|
100
|
+
</div>
|
|
101
|
+
</Transition>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
@@ -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>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
currentUserId: { type: [Number, String], default: null },
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits(['filter']);
|
|
9
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
10
|
+
const activeChip = ref(null);
|
|
11
|
+
|
|
12
|
+
const chips = computed(() => {
|
|
13
|
+
const list = [
|
|
14
|
+
{ key: 'my_tickets', label: 'My Tickets', filter: { assigned_to: props.currentUserId } },
|
|
15
|
+
{ key: 'unassigned', label: 'Unassigned', filter: { unassigned: true } },
|
|
16
|
+
{ key: 'urgent', label: 'Urgent+', filter: { priority: 'urgent' } },
|
|
17
|
+
{ key: 'sla_breaching', label: 'SLA Breaching', filter: { sla_breached: true } },
|
|
18
|
+
{ key: 'following', label: 'Following', filter: { following: true } },
|
|
19
|
+
];
|
|
20
|
+
if (props.currentUserId) return list;
|
|
21
|
+
return list.filter(c => c.key !== 'my_tickets');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function toggle(chip) {
|
|
25
|
+
if (activeChip.value === chip.key) {
|
|
26
|
+
activeChip.value = null;
|
|
27
|
+
emit('filter', {});
|
|
28
|
+
} else {
|
|
29
|
+
activeChip.value = chip.key;
|
|
30
|
+
emit('filter', chip.filter);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
37
|
+
<button v-for="chip in chips" :key="chip.key" @click="toggle(chip)"
|
|
38
|
+
:class="['rounded-full px-3 py-1.5 text-xs font-medium transition-all',
|
|
39
|
+
activeChip === chip.key
|
|
40
|
+
? (escDark
|
|
41
|
+
? 'bg-gradient-to-r from-cyan-500/20 to-violet-500/20 text-white ring-1 ring-cyan-500/30'
|
|
42
|
+
: 'bg-gradient-to-r from-blue-50 to-indigo-50 text-blue-700 ring-1 ring-blue-200')
|
|
43
|
+
: (escDark
|
|
44
|
+
? 'bg-white/[0.04] text-neutral-400 ring-1 ring-white/[0.06] hover:bg-white/[0.06] hover:text-neutral-300'
|
|
45
|
+
: 'bg-gray-50 text-gray-600 ring-1 ring-gray-200 hover:bg-gray-100')]">
|
|
46
|
+
{{ chip.label }}
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</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>
|