Files
FEDEO/frontend/pages/communication/index.vue

806 lines
25 KiB
Vue

<script setup>
const toast = useToast()
const { $api } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const status = ref(null)
const identity = ref(null)
const tenantSpace = ref(null)
const generalRoom = ref(null)
const provisionResult = ref(null)
const tenantSpaceProvisionResult = ref(null)
const generalRoomProvisionResult = ref(null)
const matrixMessages = ref([])
const matrixMembers = ref([])
const matrixMessageDraft = ref("")
const matrixMessagesViewport = ref(null)
const loading = ref(false)
const provisioning = ref(false)
const tenantSpaceProvisioning = ref(false)
const generalRoomProvisioning = ref(false)
const matrixMessagesLoading = ref(false)
const matrixMembersLoading = ref(false)
const matrixMessageSending = ref(false)
const matrixAutoRefreshActive = ref(false)
const lastUpdated = ref(null)
let matrixRefreshInterval = null
let matrixMessagesRequestActive = false
let matrixMembersRequestActive = false
const statusItems = computed(() => [
{
label: "Konfiguration",
value: status.value?.configured ? "Aktiv" : "Nicht aktiv",
icon: status.value?.configured ? "i-heroicons-check-circle" : "i-heroicons-x-circle",
color: status.value?.configured ? "success" : "error"
},
{
label: "Homeserver",
value: status.value?.homeserverUrl || "-",
icon: "i-heroicons-server-stack",
color: "neutral"
},
{
label: "Servername",
value: status.value?.serverName || "-",
icon: "i-heroicons-identification",
color: "neutral"
},
{
label: "Provisionierung",
value: status.value?.provisioningConfigured ? "Bereit" : "Nicht eingerichtet",
icon: status.value?.provisioningConfigured ? "i-heroicons-key" : "i-heroicons-exclamation-triangle",
color: status.value?.provisioningConfigured ? "success" : "warning"
},
{
label: "Erreichbarkeit",
value: status.value?.reachable ? "Erreichbar" : "Nicht erreichbar",
icon: status.value?.reachable ? "i-heroicons-signal" : "i-heroicons-signal-slash",
color: status.value?.reachable ? "success" : "error"
}
])
const elementBaseUrl = computed(() =>
String(runtimeConfig.public.matrixElementUrl || "http://localhost:8080").replace(/\/+$/, "")
)
const activeMatrixTarget = computed(() => {
if (generalRoom.value?.exists) return generalRoom.value
if (tenantSpace.value?.exists) return tenantSpace.value
return null
})
const activeMatrixTargetId = computed(() =>
activeMatrixTarget.value?.roomId || activeMatrixTarget.value?.alias || ""
)
const embeddedElementUrl = computed(() => {
if (!activeMatrixTargetId.value) return `${elementBaseUrl.value}/`
return `${elementBaseUrl.value}/#/room/${encodeURIComponent(activeMatrixTargetId.value)}`
})
const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
)
const mergeMatrixMessages = (incomingMessages) => {
const byId = new Map()
for (const message of matrixMessages.value) {
byId.set(message.id, message)
}
for (const message of incomingMessages || []) {
byId.set(message.id, {
...byId.get(message.id),
...message,
pending: false,
failed: false
})
}
matrixMessages.value = Array.from(byId.values())
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
}
const scrollMessagesToBottom = async () => {
await nextTick()
if (!matrixMessagesViewport.value) return
matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight
}
const loadMatrixInfo = async () => {
loading.value = true
try {
const [statusRes, identityRes, tenantSpaceRes, generalRoomRes] = await Promise.all([
$api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me"),
$api("/api/communication/matrix/tenant-space"),
$api("/api/communication/matrix/rooms/general")
])
status.value = statusRes
identity.value = identityRes
tenantSpace.value = tenantSpaceRes
generalRoom.value = generalRoomRes
lastUpdated.value = new Date()
if (generalRoomRes?.exists && canUseMatrixChat.value) {
await loadGeneralChat({ silent: true, includeMembers: true })
}
} catch (error) {
toast.add({
title: "Matrix-Status konnte nicht geladen werden",
color: "error"
})
} finally {
loading.value = false
}
}
const provisionMatrixAccount = async () => {
provisioning.value = true
try {
const res = await $api("/api/communication/matrix/me/provision", {
method: "POST"
})
provisionResult.value = res
identity.value = {
...identity.value,
matrixUserId: res.matrixUserId,
displayName: res.displayName
}
toast.add({
title: res.alreadyExisted ? "Matrix-Konto ist bereits vorhanden" : "Matrix-Konto erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Matrix-Konto konnte nicht erstellt werden",
color: "error"
})
} finally {
provisioning.value = false
}
}
const provisionTenantSpace = async () => {
tenantSpaceProvisioning.value = true
try {
const res = await $api("/api/communication/matrix/tenant-space/provision", {
method: "POST"
})
tenantSpaceProvisionResult.value = res
tenantSpace.value = {
tenantId: res.tenantId,
tenantName: res.tenantName,
alias: res.alias,
exists: true,
roomId: res.roomId,
servers: res.servers || []
}
toast.add({
title: res.alreadyExisted ? "Mandanten-Space ist bereits vorhanden" : "Mandanten-Space erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Mandanten-Space konnte nicht erstellt werden",
color: "error"
})
} finally {
tenantSpaceProvisioning.value = false
}
}
const provisionGeneralRoom = async () => {
generalRoomProvisioning.value = true
try {
const res = await $api("/api/communication/matrix/rooms/general/provision", {
method: "POST"
})
generalRoomProvisionResult.value = res
generalRoom.value = {
tenantId: res.tenantId,
tenantName: res.tenantName,
key: res.key,
name: res.name,
alias: res.alias,
exists: true,
roomId: res.roomId,
servers: res.servers || []
}
tenantSpace.value = {
...tenantSpace.value,
exists: true,
roomId: res.parentSpaceRoomId || tenantSpace.value?.roomId
}
toast.add({
title: res.alreadyExisted ? "Allgemeiner Chat ist bereits vorhanden" : "Allgemeiner Chat erstellt",
color: "success"
})
await loadGeneralChat({ includeMembers: true })
} catch (error) {
toast.add({
title: "Allgemeiner Chat konnte nicht erstellt werden",
color: "error"
})
} finally {
generalRoomProvisioning.value = false
}
}
const loadGeneralMessages = async ({ silent = false } = {}) => {
if (!canUseMatrixChat.value) return
if (matrixMessagesRequestActive) return
matrixMessagesRequestActive = true
if (!silent) matrixMessagesLoading.value = true
try {
const res = await $api("/api/communication/matrix/rooms/general/messages")
mergeMatrixMessages(res.messages || [])
generalRoom.value = {
...generalRoom.value,
alias: res.alias || generalRoom.value?.alias,
exists: true,
roomId: res.roomId || generalRoom.value?.roomId
}
await scrollMessagesToBottom()
} catch (error) {
if (!silent) {
toast.add({
title: "Matrix-Nachrichten konnten nicht geladen werden",
color: "error"
})
}
} finally {
matrixMessagesRequestActive = false
if (!silent) matrixMessagesLoading.value = false
}
}
const loadGeneralMembers = async ({ silent = false } = {}) => {
if (!canUseMatrixChat.value) return
if (matrixMembersRequestActive) return
matrixMembersRequestActive = true
if (!silent) matrixMembersLoading.value = true
try {
const res = await $api("/api/communication/matrix/rooms/general/members")
matrixMembers.value = res.members || []
} catch (error) {
if (!silent) {
toast.add({
title: "Matrix-Teilnehmer konnten nicht geladen werden",
color: "error"
})
}
} finally {
matrixMembersRequestActive = false
if (!silent) matrixMembersLoading.value = false
}
}
const loadGeneralChat = async ({ silent = false, includeMembers = false } = {}) => {
await loadGeneralMessages({ silent })
if (includeMembers) {
await loadGeneralMembers({ silent })
}
}
const sendMatrixMessage = async () => {
const text = matrixMessageDraft.value.trim()
if (!text) return
const optimisticId = `pending-${Date.now()}`
const optimisticMessage = {
id: optimisticId,
sender: identity.value?.matrixUserId || "Du",
senderDisplayName: identity.value?.displayName || "Du",
body: text,
timestamp: Date.now(),
own: true,
pending: true,
failed: false
}
matrixMessages.value = [
...matrixMessages.value,
optimisticMessage
]
matrixMessageDraft.value = ""
await scrollMessagesToBottom()
matrixMessageSending.value = true
try {
const message = await $api("/api/communication/matrix/rooms/general/messages", {
method: "POST",
body: { text }
})
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === optimisticId ? message : item
)
} catch (error) {
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === optimisticId ? { ...item, pending: false, failed: true } : item
)
toast.add({
title: "Matrix-Nachricht konnte nicht gesendet werden",
color: "error"
})
} finally {
matrixMessageSending.value = false
}
}
const startMatrixAutoRefresh = () => {
if (matrixRefreshInterval) return
matrixAutoRefreshActive.value = true
matrixRefreshInterval = window.setInterval(() => {
if (!document.hidden && canUseMatrixChat.value) {
loadGeneralChat({ silent: true })
}
}, 15000)
}
const stopMatrixAutoRefresh = () => {
if (!matrixRefreshInterval) return
window.clearInterval(matrixRefreshInterval)
matrixRefreshInterval = null
matrixAutoRefreshActive.value = false
}
const formatDateTime = (value) => {
if (!value) return "-"
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(value)
}
const formatMessageTime = (timestamp) => {
if (!timestamp) return ""
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(new Date(timestamp))
}
watch(
() => matrixMessages.value.length,
() => scrollMessagesToBottom()
)
onMounted(async () => {
await loadMatrixInfo()
startMatrixAutoRefresh()
})
onBeforeUnmount(stopMatrixAutoRefresh)
</script>
<template>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-highlighted">
Kommunikation
</h1>
<p class="mt-1 text-sm text-muted">
Matrix-Verbindung und persönliche Kommunikationsidentität.
</p>
</div>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="loading"
@click="loadMatrixInfo"
>
Aktualisieren
</UButton>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<UCard
v-for="item in statusItems"
:key="item.label"
:ui="{ root: 'rounded-lg', body: 'p-4 sm:p-4' }"
>
<div class="flex items-start gap-3">
<UIcon
:name="item.icon"
class="mt-0.5 size-5 shrink-0"
:class="{
'text-success': item.color === 'success',
'text-error': item.color === 'error',
'text-warning': item.color === 'warning',
'text-muted': item.color === 'neutral'
}"
/>
<div class="min-w-0">
<p class="text-xs font-medium uppercase text-muted">
{{ item.label }}
</p>
<p class="mt-1 break-words text-sm font-medium text-highlighted">
{{ item.value }}
</p>
</div>
</div>
</UCard>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
<div class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Eigene Matrix-Identität
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Matrix-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ identity?.matrixUserId || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Anzeigename
</p>
<p class="mt-1 text-sm text-highlighted">
{{ identity?.displayName || "-" }}
</p>
</div>
</div>
<UAlert
v-if="provisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="provisionResult.alreadyExisted ? 'Matrix-Konto vorhanden' : 'Matrix-Konto erstellt'"
:description="provisionResult.matrixUserId"
/>
<UAlert
v-if="status && !status.reachable"
icon="i-heroicons-exclamation-triangle"
color="error"
variant="soft"
title="Matrix-Homeserver nicht erreichbar"
:description="status.error || 'Bitte prüfe den lokalen Matrix-Stack und die Backend-Konfiguration.'"
/>
<UAlert
v-else-if="status && !status.provisioningConfigured"
icon="i-heroicons-key"
color="warning"
variant="soft"
title="Matrix-Provisionierung nicht eingerichtet"
description="Bitte setze MATRIX_REGISTRATION_SHARED_SECRET in der Backend-Umgebung."
/>
</div>
<template #footer>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-muted">
Aktualisiert: {{ formatDateTime(lastUpdated) }}
</p>
<UButton
icon="i-heroicons-user-plus"
:loading="provisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionMatrixAccount"
>
Matrix-Konto erstellen
</UButton>
</div>
</template>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-building-office-2" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Mandanten-Space
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Alias
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.alias || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Status
</p>
<UBadge
class="mt-1"
:color="tenantSpace?.exists ? 'success' : 'neutral'"
variant="soft"
>
{{ tenantSpace?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
</UBadge>
</div>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Raum-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.roomId || "-" }}
</p>
</div>
<UAlert
v-if="tenantSpaceProvisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="tenantSpaceProvisionResult.alreadyExisted ? 'Mandanten-Space vorhanden' : 'Mandanten-Space erstellt'"
:description="tenantSpaceProvisionResult.alias"
/>
</div>
<template #footer>
<div class="flex justify-end">
<UButton
icon="i-heroicons-plus"
:loading="tenantSpaceProvisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionTenantSpace"
>
Mandanten-Space erstellen
</UButton>
</div>
</template>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Allgemeiner Chat
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Alias
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ generalRoom?.alias || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Status
</p>
<UBadge
class="mt-1"
:color="generalRoom?.exists ? 'success' : 'neutral'"
variant="soft"
>
{{ generalRoom?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
</UBadge>
</div>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Raum-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ generalRoom?.roomId || "-" }}
</p>
</div>
<UAlert
v-if="generalRoomProvisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="generalRoomProvisionResult.alreadyExisted ? 'Allgemeiner Chat vorhanden' : 'Allgemeiner Chat erstellt'"
:description="generalRoomProvisionResult.alias"
/>
</div>
<template #footer>
<div class="flex justify-end">
<UButton
icon="i-heroicons-plus"
:loading="generalRoomProvisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionGeneralRoom"
>
Allgemeinen Chat erstellen
</UButton>
</div>
</template>
</UCard>
</div>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-video-camera" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Nächste Ausbaustufe
</h2>
</div>
</template>
<div class="space-y-3 text-sm text-muted">
<div class="flex gap-2">
<UIcon name="i-heroicons-building-office-2" class="mt-0.5 size-4 shrink-0" />
<span>Team- und Projekt-Räume im Mandanten-Space anlegen.</span>
</div>
<div class="flex gap-2">
<UIcon name="i-heroicons-users" class="mt-0.5 size-4 shrink-0" />
<span>FEDEO-Nutzer in Matrix-Räume synchronisieren.</span>
</div>
<div class="flex gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="mt-0.5 size-4 shrink-0" />
<span>Nativen FEDEO-Chat schrittweise an Matrix-Sync anbinden.</span>
</div>
</div>
</UCard>
</div>
<UCard :ui="{ root: 'rounded-lg overflow-hidden', body: 'p-0 sm:p-0' }">
<template #header>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5 shrink-0 text-primary" />
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-base font-semibold text-highlighted">
Matrix-Kommunikation
</h2>
<UBadge
v-if="matrixAutoRefreshActive"
color="success"
variant="soft"
size="xs"
>
Live
</UBadge>
</div>
<p class="mt-1 truncate text-xs text-muted">
{{ generalRoom?.name || generalRoom?.alias || "Allgemeiner Chat" }}
<span v-if="matrixMembers.length"> · {{ matrixMembers.length }} Teilnehmer</span>
</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="matrixMessagesLoading"
:disabled="!canUseMatrixChat"
@click="loadGeneralChat"
>
Nachrichten laden
</UButton>
<UButton
icon="i-heroicons-arrow-top-right-on-square"
color="neutral"
variant="outline"
:to="embeddedElementUrl"
target="_blank"
>
Element öffnen
</UButton>
</div>
</div>
</template>
<div class="flex h-[640px] min-h-[520px] flex-col bg-muted">
<div
ref="matrixMessagesViewport"
class="flex-1 space-y-3 overflow-y-auto p-4 sm:p-5"
>
<div
v-if="!matrixMessages.length && !matrixMessagesLoading"
class="flex h-full min-h-64 items-center justify-center text-sm text-muted"
>
Noch keine Nachrichten im allgemeinen Chat.
</div>
<div
v-for="message in matrixMessages"
:key="message.id"
class="flex"
:class="message.own ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[78%] rounded-lg px-3 py-2 shadow-sm"
:class="[
message.own ? 'bg-primary text-inverted' : 'bg-default text-highlighted',
message.pending ? 'opacity-70' : '',
message.failed ? 'bg-error text-inverted' : ''
]"
>
<div class="mb-1 flex items-center gap-2 text-[11px] opacity-75">
<span class="truncate font-medium">
{{ message.own ? "Du" : message.senderDisplayName || message.sender }}
</span>
<span>{{ formatMessageTime(message.timestamp) }}</span>
<span v-if="message.pending">wird gesendet</span>
<span v-if="message.failed">nicht gesendet</span>
</div>
<p class="whitespace-pre-wrap break-words text-sm">
{{ message.body }}
</p>
</div>
</div>
</div>
<form
class="flex gap-2 border-t border-default bg-default p-3"
@submit.prevent="sendMatrixMessage"
>
<UInput
v-model="matrixMessageDraft"
class="min-w-0 flex-1"
placeholder="Nachricht schreiben"
:disabled="matrixMessageSending || !canUseMatrixChat"
@keydown.enter.exact.prevent="sendMatrixMessage"
/>
<UButton
type="submit"
icon="i-heroicons-paper-airplane"
:loading="matrixMessageSending"
:disabled="!matrixMessageDraft.trim() || !canUseMatrixChat"
>
Senden
</UButton>
</form>
</div>
</UCard>
</div>
</div>
</template>