Files
FEDEO/frontend/pages/helpdesk/[[id]].vue
2026-01-06 12:09:31 +01:00

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>