Introduced Helpdesk
This commit is contained in:
@@ -95,6 +95,11 @@ const links = computed(() => {
|
|||||||
icon: "i-heroicons-megaphone",
|
icon: "i-heroicons-megaphone",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
label: "Helpdesk",
|
||||||
|
to: "/helpdesk",
|
||||||
|
icon: "i-heroicons-chat-bubble-left-right"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "E-Mail",
|
label: "E-Mail",
|
||||||
to: "/email/new",
|
to: "/email/new",
|
||||||
|
|||||||
110
composables/useHelpdesk.ts
Normal file
110
composables/useHelpdesk.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// composables/useHelpdeskApi.ts
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useNuxtApp } from '#app'
|
||||||
|
|
||||||
|
export function useHelpdeskApi() {
|
||||||
|
const { $api } = useNuxtApp()
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const base = '/api/helpdesk'
|
||||||
|
|
||||||
|
// 🔹 Konversationen abrufen
|
||||||
|
async function getConversations(status?: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const query = status ? `?status=${status}` : ''
|
||||||
|
const data = await $api(`${base}/conversations${query}`)
|
||||||
|
return data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Fehler beim Laden der Konversationen'
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Einzelne Konversation
|
||||||
|
async function getConversation(id: string) {
|
||||||
|
try {
|
||||||
|
return await $api(`${base}/conversations/${id}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Nachrichten einer Konversation
|
||||||
|
async function getMessages(conversationId: string) {
|
||||||
|
try {
|
||||||
|
return await $api(`${base}/conversations/${conversationId}/messages`)
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Neue Nachricht senden
|
||||||
|
async function sendMessage(conversationId: string, text: string) {
|
||||||
|
try {
|
||||||
|
return await $api(`${base}/conversations/${conversationId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { text },
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyMessage(conversationId: string, text: string) {
|
||||||
|
try {
|
||||||
|
return await $api(`${base}/conversations/${conversationId}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { text },
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Neuen Kontakt (manuell) anlegen
|
||||||
|
async function createContact(payload: { email?: string; phone?: string; display_name?: string }) {
|
||||||
|
try {
|
||||||
|
return await $api(`${base}/contacts`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Konversation-Status ändern
|
||||||
|
async function updateConversationStatus(conversationId: string, status: string) {
|
||||||
|
try {
|
||||||
|
return await $api(`${base}/conversations/${conversationId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { status },
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
getConversations,
|
||||||
|
getConversation,
|
||||||
|
getMessages,
|
||||||
|
sendMessage,
|
||||||
|
createContact,
|
||||||
|
updateConversationStatus,
|
||||||
|
replyMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
325
pages/helpdesk/[[id]].vue
Normal file
325
pages/helpdesk/[[id]].vue
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, onMounted, watch} from 'vue'
|
||||||
|
import {format, isToday, formatDistanceToNow} from 'date-fns'
|
||||||
|
import {de as deLocale} from 'date-fns/locale'
|
||||||
|
|
||||||
|
const {getConversations, getMessages, sendMessage, replyMessage, updateConversationStatus} = useHelpdeskApi()
|
||||||
|
|
||||||
|
const conversations = ref<any[]>([])
|
||||||
|
const selectedConversation = ref<any>(null)
|
||||||
|
const messages = ref<any[]>([])
|
||||||
|
const messageText = ref('')
|
||||||
|
const filterStatus = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Referenzen für Scroll + Shortcuts
|
||||||
|
const convRefs = ref<Element[]>([])
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
loading.value = true
|
||||||
|
conversations.value = await getConversations(filterStatus.value)
|
||||||
|
|
||||||
|
if(route.params.id){
|
||||||
|
await selectConversation(conversations.value.find(i => i.id === route.params.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(conv: any) {
|
||||||
|
selectedConversation.value = conv
|
||||||
|
messages.value = await getMessages(conv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!messageText.value || !selectedConversation.value) return
|
||||||
|
await replyMessage(selectedConversation.value.id, messageText.value)
|
||||||
|
messageText.value = ''
|
||||||
|
messages.value = await getMessages(selectedConversation.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
defineShortcuts({
|
||||||
|
arrowdown: () => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === selectedConversation.value?.id)
|
||||||
|
if (index === -1) selectedConversation.value = conversations.value[0]
|
||||||
|
else if (index < conversations.value.length - 1)
|
||||||
|
selectedConversation.value = conversations.value[index + 1]
|
||||||
|
},
|
||||||
|
arrowup: () => {
|
||||||
|
const index = conversations.value.findIndex(c => c.id === selectedConversation.value?.id)
|
||||||
|
if (index === -1) selectedConversation.value = conversations.value.at(-1)
|
||||||
|
else if (index > 0)
|
||||||
|
selectedConversation.value = conversations.value[index - 1]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedConversation, () => {
|
||||||
|
if (!selectedConversation.value) return
|
||||||
|
const ref = convRefs.value[selectedConversation.value.id]
|
||||||
|
if (ref) ref.scrollIntoView({block: 'nearest'})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadConversations)
|
||||||
|
watch(filterStatus, loadConversations)
|
||||||
|
|
||||||
|
// Gruppierung nach aufeinanderfolgenden gleichen Autoren
|
||||||
|
const groupedMessages = computed(() => {
|
||||||
|
if (!messages.value.length) return []
|
||||||
|
|
||||||
|
const groups: any[] = []
|
||||||
|
let current: any = null
|
||||||
|
|
||||||
|
for (const msg of messages.value) {
|
||||||
|
const authorKey = `${msg.direction}-${msg.author_user_id || msg.author_name || 'anon'}`
|
||||||
|
if (!current || current.key !== authorKey) {
|
||||||
|
current = {
|
||||||
|
key: authorKey,
|
||||||
|
direction: msg.direction,
|
||||||
|
author_name: msg.direction === 'outgoing' ? 'Du' : msg.author_name || 'Kunde',
|
||||||
|
author_avatar: msg.author_avatar || null,
|
||||||
|
messages: [msg],
|
||||||
|
latest_created_at: msg.created_at,
|
||||||
|
}
|
||||||
|
groups.push(current)
|
||||||
|
} else {
|
||||||
|
current.messages.push(msg)
|
||||||
|
current.latest_created_at = msg.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage>
|
||||||
|
<!-- === NAVBAR === -->
|
||||||
|
<UDashboardNavbar>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon name="i-heroicons-chat-bubble-left-right" class="w-5 h-5 text-primary-600"/>
|
||||||
|
<span class="text-lg font-semibold">Helpdesk</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
@click="loadConversations"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
<!-- === TOOLBAR === -->
|
||||||
|
<UDashboardToolbar>
|
||||||
|
<template #left>
|
||||||
|
<USelect
|
||||||
|
v-model="filterStatus"
|
||||||
|
size="sm"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Alle', value: '' },
|
||||||
|
{ label: 'Offen', value: 'open' },
|
||||||
|
{ label: 'In Bearbeitung', value: 'in_progress' },
|
||||||
|
{ label: 'Geschlossen', value: 'closed' }
|
||||||
|
]"
|
||||||
|
placeholder="Status filtern"
|
||||||
|
class="min-w-[180px]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
size="sm"
|
||||||
|
label="Konversation"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UDashboardToolbar>
|
||||||
|
|
||||||
|
<!-- === CONTENT === -->
|
||||||
|
<div class="flex h-[calc(100vh-150px)] overflow-x-hidden">
|
||||||
|
<!-- 📬 Resizable Sidebar -->
|
||||||
|
<div
|
||||||
|
class="relative flex-shrink-0 border-r border-(--ui-border) bg-(--ui-bg) overflow-y-auto"
|
||||||
|
style="width: 340px; resize: horizontal; min-width: 260px; max-width: 600px;"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="p-4 space-y-2">
|
||||||
|
<USkeleton v-for="i in 6" :key="i" class="h-14"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="divide-y divide-(--ui-border)">
|
||||||
|
<div
|
||||||
|
v-for="(conv, index) in conversations"
|
||||||
|
:key="conv.id"
|
||||||
|
:ref="el => { convRefs[conv.id] = el as Element }"
|
||||||
|
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
||||||
|
:class="[
|
||||||
|
selectedConversation && selectedConversation.id === conv.id
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-(--ui-bg) hover:border-primary hover:bg-primary/5'
|
||||||
|
]"
|
||||||
|
@click="selectConversation(conv)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between font-medium">
|
||||||
|
<div class="flex items-center gap-2 truncate">
|
||||||
|
{{ conv.helpdesk_contacts?.display_name || 'Unbekannt' }}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{
|
||||||
|
isToday(new Date(conv.last_message_at || conv.created_at))
|
||||||
|
? format(new Date(conv.last_message_at || conv.created_at), 'HH:mm')
|
||||||
|
: format(new Date(conv.last_message_at || conv.created_at), 'dd MMM')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="truncate text-sm font-semibold">
|
||||||
|
{{conv.ticket_number}} | {{ conv.subject || 'Ohne Betreff' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-dimmed line-clamp-1">
|
||||||
|
{{ conv.last_message_preview || '...' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 💬 Conversation Panel -->
|
||||||
|
<div class="flex-1 flex flex-col overflow-x-hidden" v-if="selectedConversation">
|
||||||
|
<UCard class="relative flex flex-col flex-1 rounded-none border-0 border-l border-(--ui-border)">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold truncate text-gray-800 dark:text-gray-200">
|
||||||
|
{{selectedConversation.ticket_number}} | {{ selectedConversation.subject || 'Ohne Betreff' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USelect
|
||||||
|
v-model="selectedConversation.status"
|
||||||
|
size="xs"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Offen', value: 'open' },
|
||||||
|
{ label: 'In Bearbeitung', value: 'in_progress' },
|
||||||
|
{ label: 'Geschlossen', value: 'closed' }
|
||||||
|
]"
|
||||||
|
@update:model-value="val => updateConversationStatus(selectedConversation.id, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kundenzuordnung -->
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<UIcon
|
||||||
|
:name="selectConversation.customer_id?.isCompany ? 'i-heroicons-building-office-2' : 'i-heroicons-user'"
|
||||||
|
class="w-4 h-4 text-gray-400"/>
|
||||||
|
<span>
|
||||||
|
<strong>{{ selectedConversation.customer_id?.name || 'Kein Kunde zugeordnet' }}</strong>
|
||||||
|
</span>
|
||||||
|
<EntityModalButtons
|
||||||
|
type="customers"
|
||||||
|
v-if="selectedConversation?.customer_id?.id"
|
||||||
|
:id="selectedConversation?.customer_id?.id"
|
||||||
|
:button-edit="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="selectedConversation.contact_person">
|
||||||
|
<UIcon name="i-heroicons-user" class="w-4 h-4 text-gray-400 ml-3"/>
|
||||||
|
<span>{{ selectedConversation.contact_person.name }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Nachrichten -->
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-4 p-4 pb-24">
|
||||||
|
<template v-for="(group, gIndex) in groupedMessages" :key="gIndex">
|
||||||
|
<!-- Avatar + Name + Zeit -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 mb-1"
|
||||||
|
:class="group.direction === 'outgoing' ? 'flex-row-reverse text-right' : 'flex-row text-left'"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ group.direction === 'outgoing' ? 'Du' : group.author_name || 'Kunde' }}
|
||||||
|
•
|
||||||
|
{{ formatDistanceToNow(new Date(group.latest_created_at), {addSuffix: true, locale: deLocale}) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nachrichten des Autors -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="msg in group.messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="flex"
|
||||||
|
:class="group.direction === 'outgoing' ? 'justify-end' : 'justify-start'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'inline-block px-3 py-2 rounded-xl max-w-[80%] text-sm whitespace-pre-wrap break-words',
|
||||||
|
msg.direction === 'outgoing'
|
||||||
|
? 'bg-primary-500 text-white ml-auto'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-50'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ msg.payload.text }}
|
||||||
|
|
||||||
|
<!-- Gelesen-Indikator (nur outgoing letzte Nachricht) -->
|
||||||
|
<span
|
||||||
|
v-if="group.direction === 'outgoing' && msg.id === group.messages.at(-1).id"
|
||||||
|
class="absolute -bottom-4 right-1 text-[10px] text-gray-400 flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
v-if="msg.read"
|
||||||
|
name="i-heroicons-check-double-16-solid"
|
||||||
|
class="text-primary-400 w-3 h-3"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
v-else
|
||||||
|
name="i-heroicons-check-16-solid"
|
||||||
|
class="text-gray-400 w-3 h-3"
|
||||||
|
/>
|
||||||
|
<span>{{ msg.read ? 'Gelesen' : 'Gesendet' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="!messages.length" class="text-center text-gray-500 text-sm mt-4">
|
||||||
|
Keine Nachrichten vorhanden
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Nachricht senden (jetzt sticky unten) -->
|
||||||
|
<form class="sticky bottom-0 border-t flex gap-2 p-3 bg-white dark:bg-gray-900" @submit.prevent="send">
|
||||||
|
<UInput
|
||||||
|
v-model="messageText"
|
||||||
|
placeholder="Nachricht eingeben..."
|
||||||
|
class="flex-1"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:disabled="!messageText"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
label="Senden"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user