Introduced Helpdesk
This commit is contained in:
@@ -95,6 +95,11 @@ const links = computed(() => {
|
||||
icon: "i-heroicons-megaphone",
|
||||
defaultOpen: false,
|
||||
children: [
|
||||
{
|
||||
label: "Helpdesk",
|
||||
to: "/helpdesk",
|
||||
icon: "i-heroicons-chat-bubble-left-right"
|
||||
},
|
||||
{
|
||||
label: "E-Mail",
|
||||
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