326 lines
11 KiB
Vue
326 lines
11 KiB
Vue
<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>
|