KI-AGENT: Telefonie Nebenstellen in Einstellungen integrieren

This commit is contained in:
2026-05-22 15:55:06 +02:00
parent da9cad1513
commit 520052e71a
12 changed files with 922 additions and 780 deletions

View File

@@ -400,6 +400,32 @@ const canArchiveItem = computed(() => {
return true
})
const telephonyExtensionTarget = computed(() => {
if (!item.value?.id) return null
if (type === "teams") {
return {
targetType: "team",
targetId: item.value.id,
displayName: item.value.name || "Team",
title: "Telefonie",
description: "Lege fest, unter welcher Nebenstelle dieses Team erreichbar ist."
}
}
if (type === "branches") {
return {
targetType: "branch",
targetId: item.value.id,
displayName: item.value.name || "Niederlassung",
title: "Telefonie",
description: "Lege fest, unter welcher Nebenstelle diese Niederlassung erreichbar ist."
}
}
return null
})
const createItem = async () => {
let ret = null
@@ -1036,6 +1062,16 @@ const updateItem = async () => {
</InputGroup>
</UFormField>
</UForm>
<TelephonyExtensionField
v-if="telephonyExtensionTarget"
class="mx-5 mb-5"
:target-type="telephonyExtensionTarget.targetType"
:target-id="telephonyExtensionTarget.targetId"
:display-name="telephonyExtensionTarget.displayName"
:title="telephonyExtensionTarget.title"
:description="telephonyExtensionTarget.description"
/>
</UDashboardPanelContent>
</template>

View File

@@ -85,11 +85,6 @@ const links = computed(() => {
to: "/communication/phone",
icon: "i-heroicons-phone"
},
{
label: "Telefonie Setup",
to: "/communication/phone-setup",
icon: "i-heroicons-cog-6-tooth"
},
featureEnabled("helpdesk") ? {
label: "Helpdesk",
to: "/helpdesk",
@@ -342,6 +337,11 @@ const links = computed(() => {
to: "/settings/tenant",
icon: "i-heroicons-building-office",
} : null,
{
label: "Telefonie",
to: "/settings/telephony",
icon: "i-heroicons-phone",
},
{
label: "Matrix-Setup",
to: "/communication",

View File

@@ -0,0 +1,197 @@
<script setup>
const props = defineProps({
targetType: {
type: String,
required: true
},
targetId: {
type: [String, Number],
default: null
},
displayName: {
type: String,
default: ""
},
title: {
type: String,
default: "Telefonie"
},
description: {
type: String,
default: "Lege fest, unter welcher internen Nebenstelle dieses Ziel erreichbar ist."
}
})
const emit = defineEmits(["saved"])
const toast = useToast()
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const extension = ref("")
const enabled = ref(true)
const existingId = ref(null)
const canUseExtension = computed(() => Boolean(props.targetId))
const statusLabel = computed(() => {
if (!canUseExtension.value) return "Kein Ziel verknüpft"
if (!extension.value) return "Keine Nebenstelle"
return enabled.value ? `Nebenstelle ${extension.value}` : `Nebenstelle ${extension.value} inaktiv`
})
const statusColor = computed(() => {
if (!canUseExtension.value) return "neutral"
if (!extension.value) return "neutral"
return enabled.value ? "success" : "warning"
})
const loadExtension = async () => {
if (!canUseExtension.value) return
loading.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/extensions/target", {
params: {
targetType: props.targetType,
targetId: props.targetId
}
})
existingId.value = res?.extension?.id || null
extension.value = res?.extension?.extension || ""
enabled.value = res?.extension ? res.extension.enabled !== false : true
} catch (error) {
toast.add({
title: "Nebenstelle konnte nicht geladen werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
loading.value = false
}
}
const saveExtension = async () => {
if (!canUseExtension.value || saving.value) return
saving.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/extensions/target", {
method: "PUT",
body: {
targetType: props.targetType,
targetId: props.targetId,
extension: extension.value,
displayName: props.displayName,
enabled: enabled.value
}
})
existingId.value = res?.id || existingId.value
extension.value = res?.extension || extension.value
enabled.value = res?.enabled !== false
toast.add({ title: "Nebenstelle gespeichert", color: "success" })
emit("saved", res)
} catch (error) {
toast.add({
title: "Nebenstelle konnte nicht gespeichert werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
saving.value = false
}
}
const deleteExtension = async () => {
if (!canUseExtension.value || deleting.value) return
deleting.value = true
try {
await useNuxtApp().$api("/api/telephony/extensions/target", {
method: "DELETE",
params: {
targetType: props.targetType,
targetId: props.targetId
}
})
existingId.value = null
extension.value = ""
enabled.value = true
toast.add({ title: "Nebenstelle entfernt", color: "success" })
emit("saved", null)
} catch (error) {
toast.add({
title: "Nebenstelle konnte nicht entfernt werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
deleting.value = false
}
}
watch(() => [props.targetType, props.targetId], loadExtension, { immediate: true })
</script>
<template>
<UCard>
<template #header>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-highlighted">
{{ title }}
</h3>
<p class="mt-1 text-sm text-muted">
{{ description }}
</p>
</div>
<UBadge :color="statusColor" variant="soft">
{{ statusLabel }}
</UBadge>
</div>
</template>
<UAlert
v-if="!canUseExtension"
color="warning"
icon="i-heroicons-exclamation-triangle"
title="Noch nicht verfügbar"
description="Speichere den Datensatz zuerst oder verknüpfe einen Benutzer, bevor eine Nebenstelle vergeben wird."
/>
<div v-else class="grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<UFormField label="Nebenstelle">
<UInput
v-model="extension"
inputmode="tel"
placeholder="z. B. 1001"
:loading="loading"
/>
</UFormField>
<UFormField label="Aktiv">
<USwitch v-model="enabled" />
</UFormField>
</div>
<div class="flex flex-wrap gap-2 md:justify-end">
<UButton
icon="i-heroicons-check"
:loading="saving"
:disabled="!extension"
@click="saveExtension"
>
Speichern
</UButton>
<UButton
v-if="existingId"
icon="i-heroicons-trash"
color="error"
variant="soft"
:loading="deleting"
@click="deleteExtension"
>
Entfernen
</UButton>
</div>
</div>
</UCard>
</template>

View File

@@ -40,8 +40,8 @@ export const useTelephonySoftphone = () => {
})
const selectedAccount = computed(() =>
(config.value?.testAccounts || []).find((account) => account.extension === selectedExtension.value)
|| config.value?.testAccounts?.[0]
(config.value?.accounts || []).find((account) => account.extension === selectedExtension.value)
|| (config.value?.accounts || [])[0]
)
const canRegisterSip = computed(() =>
@@ -324,8 +324,9 @@ export const useTelephonySoftphone = () => {
status.value = statusRes
lastUpdated.value = new Date()
if (!selectedAccount.value && configRes?.testAccounts?.length) {
selectedExtension.value = configRes.testAccounts[0].extension
const accounts = configRes?.accounts || []
if (!selectedAccount.value && accounts.length) {
selectedExtension.value = accounts[0].extension
}
await loadCallHistory()

View File

@@ -1,451 +1,7 @@
<script setup>
const toast = useToast()
const {
loading,
statusLoading,
websocketTesting,
config,
status,
websocketResult,
lastUpdated,
selectedExtension,
sipRegistered,
sipStatus,
registererState,
sipEvents,
statusColor,
statusIcon,
websocketColor,
loadTelephony,
refreshStatus,
testWebSocket,
} = useTelephonySoftphone()
const trunkStatus = ref(null)
const trunkStatusLoading = ref(false)
const trunkApplying = ref(false)
const loadTrunkStatus = async () => {
trunkStatusLoading.value = true
try {
trunkStatus.value = await useNuxtApp().$api("/api/telephony/trunk-status")
} catch (error) {
trunkStatus.value = {
reachable: false,
registered: false,
hasRegistration: false,
message: error?.data?.error || error?.message || "Trunk-Status konnte nicht geladen werden."
}
} finally {
trunkStatusLoading.value = false
}
}
const applyTrunk = async () => {
trunkApplying.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config/apply", {
method: "POST"
})
trunkStatus.value = res?.status || trunkStatus.value
toast.add({
title: res?.warning ? "Trunk-Konfiguration geschrieben" : "Trunk angewendet",
description: res?.warning || (res?.status?.registered ? "Trunk-Registration ist aktiv." : "Asterisk wurde neu geladen."),
color: res?.warning ? "orange" : "success"
})
} catch (error) {
toast.add({
title: "Trunk konnte nicht angewendet werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
trunkApplying.value = false
}
}
onMounted(async () => {
await loadTelephony()
await loadTrunkStatus()
})
await navigateTo("/settings/telephony", { replace: true })
</script>
<template>
<div class="min-h-0 flex-1 overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8">
<div class="mx-auto flex max-w-7xl flex-col gap-6">
<div class="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-sm font-medium text-primary-600">
Kommunikation
</p>
<h1 class="mt-1 text-2xl font-semibold text-gray-950">
Telefonie Setup
</h1>
<p class="mt-2 max-w-3xl text-sm text-gray-600">
Asterisk-Status, Browser-Verbindung und lokale Test-Nebenstellen.
</p>
</div>
<div class="flex flex-wrap gap-2">
<UButton
icon="i-heroicons-arrow-path"
variant="soft"
:loading="loading"
@click="loadTelephony"
>
Aktualisieren
</UButton>
<UButton
to="/communication/phone"
icon="i-heroicons-phone"
variant="outline"
>
Zur Telefonie
</UButton>
</div>
</div>
<UCard>
<template #header>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-gray-950">
Externe Telefonie
</h2>
<p class="mt-1 text-sm text-gray-500">
SIP-Trunk-Anbindung über den lokalen Asterisk.
</p>
</div>
<UBadge :color="config?.external?.enabled ? 'success' : 'neutral'" variant="soft">
{{ config?.external?.enabled ? "Aktiviert" : "Deaktiviert" }}
</UBadge>
</div>
</template>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-building-office-2" class="text-gray-500" />
<span class="text-sm font-medium text-gray-700">Provider</span>
</div>
<p class="mt-3 text-lg font-semibold text-gray-950">
{{ config?.external?.provider || "-" }}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-globe-alt" class="text-gray-500" />
<span class="text-sm font-medium text-gray-700">Registrar</span>
</div>
<p class="mt-3 break-all font-mono text-sm font-semibold text-gray-950">
{{ config?.external?.registrar || "-" }}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-phone-arrow-down-left" class="text-gray-500" />
<span class="text-sm font-medium text-gray-700">Eingehend</span>
</div>
<p class="mt-3 text-lg font-semibold text-gray-950">
{{ config?.external?.inboundExtension || "-" }}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-phone-arrow-up-right" class="text-gray-500" />
<span class="text-sm font-medium text-gray-700">Ausgehend</span>
</div>
<p class="mt-3 text-lg font-semibold text-gray-950">
Prefix {{ config?.external?.outboundPrefix || "0" }}
</p>
</div>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<UAlert
:color="config?.external?.sipUserConfigured ? 'success' : 'warning'"
:icon="config?.external?.sipUserConfigured ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
title="SIP-ID"
:description="config?.external?.sipUserConfigured ? 'Konfiguriert' : 'Fehlt'"
/>
<UAlert
:color="config?.external?.passwordConfigured ? 'success' : 'warning'"
:icon="config?.external?.passwordConfigured ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
title="Kennwort"
:description="config?.external?.passwordConfigured ? 'Konfiguriert' : 'Fehlt'"
/>
<UAlert
:color="config?.external?.authUserConfigured ? 'success' : 'neutral'"
:icon="config?.external?.authUserConfigured ? 'i-heroicons-check-circle' : 'i-heroicons-information-circle'"
title="Auth-User"
:description="config?.external?.authUserConfigured ? 'Konfiguriert' : 'Optional'"
/>
<UAlert
:color="config?.external?.callerIdConfigured ? 'success' : 'neutral'"
:icon="config?.external?.callerIdConfigured ? 'i-heroicons-check-circle' : 'i-heroicons-information-circle'"
title="Absendernummer"
:description="config?.external?.callerIdConfigured ? 'Konfiguriert' : 'Optional'"
/>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-[1fr_auto] lg:items-center">
<UAlert
:color="trunkStatus?.registered ? 'success' : (trunkStatus?.reachable ? 'warning' : 'neutral')"
:icon="trunkStatus?.registered ? 'i-heroicons-check-circle' : (trunkStatus?.reachable ? 'i-heroicons-exclamation-triangle' : 'i-heroicons-signal-slash')"
:title="trunkStatus?.registered ? 'SIP-Trunk registriert' : (trunkStatus?.hasRegistration ? 'SIP-Trunk nicht registriert' : 'Keine Trunk-Registration aktiv')"
:description="trunkStatus?.message || (trunkStatus?.reachable ? 'Asterisk-AMI ist erreichbar.' : 'Asterisk-AMI ist noch nicht erreichbar.')"
/>
<div class="flex flex-wrap gap-2 lg:justify-end">
<UButton
icon="i-heroicons-signal"
variant="outline"
:loading="trunkStatusLoading"
@click="loadTrunkStatus"
>
Status prüfen
</UButton>
<UButton
icon="i-heroicons-arrow-path-rounded-square"
variant="soft"
:loading="trunkApplying"
@click="applyTrunk"
>
Trunk anwenden
</UButton>
</div>
</div>
</UCard>
<div class="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<UCard>
<template #header>
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold text-gray-950">
Asterisk-Status
</h2>
<p class="mt-1 text-sm text-gray-500">
Backend-Abfrage gegen den lokalen Telefonie-Stack.
</p>
</div>
<UButton
icon="i-heroicons-signal"
variant="ghost"
:loading="statusLoading"
@click="refreshStatus"
/>
</div>
</template>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center gap-2">
<UIcon :name="statusIcon" :class="status?.reachable ? 'text-green-600' : 'text-amber-600'" />
<span class="text-sm font-medium text-gray-700">Status</span>
</div>
<p class="mt-3 text-lg font-semibold text-gray-950">
{{ status?.reachable ? "Erreichbar" : (status?.enabled ? "Nicht erreichbar" : "Deaktiviert") }}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-server-stack" class="text-gray-500" />
<span class="text-sm font-medium text-gray-700">Provider</span>
</div>
<p class="mt-3 text-lg font-semibold text-gray-950">
{{ config?.provider || "asterisk" }}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-phone-arrow-up-right" class="text-gray-500" />
<span class="text-sm font-medium text-gray-700">Echo-Test</span>
</div>
<p class="mt-3 text-lg font-semibold text-gray-950">
{{ config?.echoExtension || "600" }}
</p>
</div>
</div>
<UAlert
class="mt-4"
:color="statusColor"
:icon="statusIcon"
:title="status?.message || 'Telefonie wird geladen'"
:description="status?.statusUrl || 'Noch keine Status-URL geladen.'"
/>
<div v-if="status?.attempts?.length" class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium uppercase tracking-wide text-gray-500">
Geprüfte Status-Ziele
</p>
<div class="mt-2 grid gap-2">
<div
v-for="attempt in status.attempts"
:key="attempt.url"
class="flex items-center justify-between gap-3 text-sm"
>
<span class="break-all font-mono text-gray-700">{{ attempt.url }}</span>
<UBadge :color="attempt.reachable ? 'success' : 'neutral'" variant="soft">
{{ attempt.reachable ? `HTTP ${attempt.statusCode}` : "offline" }}
</UBadge>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="text-base font-semibold text-gray-950">
Lokalen Stack starten
</h2>
<p class="mt-1 text-sm text-gray-500">
Asterisk läuft getrennt vom normalen Stack im Compose-Profil.
</p>
</div>
</template>
<div class="rounded-lg bg-gray-950 p-4 font-mono text-sm text-gray-100">
docker compose --profile telephony-dev up -d asterisk-dev
</div>
<div class="mt-4 grid gap-3 text-sm text-gray-600">
<div class="flex items-center justify-between gap-4">
<span>SIP-Domain</span>
<span class="font-mono text-gray-950">{{ config?.sipDomain || "localhost" }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span>WebSocket</span>
<span class="break-all text-right font-mono text-gray-950">{{ config?.sipWebSocketUrl || "-" }}</span>
</div>
</div>
</UCard>
</div>
<div class="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
<UCard>
<template #header>
<div>
<h2 class="text-base font-semibold text-gray-950">
Browser-Test
</h2>
<p class="mt-1 text-sm text-gray-500">
Prüft, ob FEDEO den SIP-WebSocket im Browser öffnen kann.
</p>
</div>
</template>
<div class="flex flex-col gap-3">
<UButton
icon="i-heroicons-bolt"
:loading="websocketTesting"
:disabled="!config?.sipWebSocketUrl"
@click="testWebSocket"
>
WebSocket prüfen
</UButton>
<UAlert
v-if="websocketResult"
:color="websocketColor"
:icon="websocketResult.ok ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'"
:title="websocketResult.message"
/>
</div>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="text-base font-semibold text-gray-950">
Test-Nebenstellen
</h2>
<p class="mt-1 text-sm text-gray-500">
Für lokale Call-Tests zwischen zwei Browser-Sessions.
</p>
</div>
</template>
<div class="grid gap-3 sm:grid-cols-2">
<div
v-for="account in config?.testAccounts || []"
:key="account.extension"
class="rounded-lg border border-gray-200 bg-white p-4"
>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-circle" class="text-primary-600" />
<h3 class="font-semibold text-gray-950">
{{ account.displayName }}
</h3>
</div>
<dl class="mt-4 grid gap-2 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-gray-500">Nebenstelle</dt>
<dd class="font-mono text-gray-950">{{ account.extension }}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-gray-500">Passwort</dt>
<dd class="font-mono text-gray-950">{{ account.password }}</dd>
</div>
</dl>
</div>
</div>
</UCard>
</div>
<UCard>
<template #header>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-gray-950">
SIP-Diagnose
</h2>
<p class="mt-1 text-sm text-gray-500">
Aktuelle Registrierung und letzte SIP-Ereignisse.
</p>
</div>
<div class="flex flex-wrap gap-2">
<UBadge :color="sipRegistered ? 'success' : 'neutral'" variant="soft">
{{ sipStatus }}
</UBadge>
<UBadge color="neutral" variant="soft">
Nebenstelle {{ selectedExtension }}
</UBadge>
<UBadge color="neutral" variant="soft">
{{ registererState }}
</UBadge>
</div>
</div>
</template>
<div v-if="sipEvents.length" class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<ul class="space-y-1 text-xs text-gray-600">
<li
v-for="event in sipEvents"
:key="`${event.time}-${event.message}`"
class="grid grid-cols-[4.5rem_1fr] gap-2"
>
<span class="font-mono text-gray-400">{{ event.time }}</span>
<span class="break-words">{{ event.message }}</span>
</li>
</ul>
</div>
<p v-else class="text-sm text-gray-500">
Noch keine SIP-Ereignisse vorhanden.
</p>
</UCard>
<p v-if="lastUpdated" class="text-xs text-gray-500">
Zuletzt geprüft: {{ lastUpdated.toLocaleString("de-DE") }}
</p>
</div>
</div>
<div />
</template>

View File

@@ -137,7 +137,7 @@ onMounted(loadTelephony)
<label class="text-sm font-medium text-gray-700">Nebenstelle</label>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<button
v-for="account in config?.testAccounts || []"
v-for="account in config?.accounts || []"
:key="account.extension"
type="button"
class="rounded-lg border px-4 py-3 text-left transition"
@@ -149,6 +149,9 @@ onMounted(loadTelephony)
<span class="mt-1 block text-xs opacity-75">{{ account.displayName }}</span>
</button>
</div>
<p v-if="!(config?.accounts || []).length" class="mt-2 text-sm text-gray-500">
Für deinen Benutzer ist noch keine Nebenstelle hinterlegt.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">

View File

@@ -0,0 +1,406 @@
<script setup>
const toast = useToast()
const telephonyTrunkLoading = ref(false)
const telephonyTrunkSaving = ref(false)
const telephonyTrunkApplying = ref(false)
const telephonyExtensionsLoading = ref(false)
const trunkStatusLoading = ref(false)
const trunkStatus = ref(null)
const telephonyExtensions = ref([])
const telephonyProviderOptions = [
{ label: "Easybell", value: "easybell" },
{ label: "Telekom", value: "telekom" }
]
const telephonyProviderDefaults = {
easybell: {
registrar: "voip.easybell.de",
title: "Easybell SIP-Trunk",
description: "Trage die SIP-Daten aus dem Easybell-Kundenportal ein. Die Absendernummer ist die Stammnummer im internationalen Format ohne führende 00."
},
telekom: {
registrar: "tel.t-online.de",
title: "Telekom Zugangsdaten",
description: "Die SIP-ID ist meistens die Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen. Klassische Zugangsdaten können als Auth-User hinterlegt werden."
}
}
const telephonyTrunkForm = reactive({
provider: "easybell",
enabled: false,
registrar: "voip.easybell.de",
sipUser: "",
authUser: "",
password: "",
passwordConfigured: false,
clearPassword: false,
callerId: "",
inboundExtension: "1001",
defaultRouteExtensionId: null,
outboundPrefix: "0",
externalSignalingAddress: "",
externalMediaAddress: "",
localNetworks: "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
})
const activeTelephonyProvider = computed(() =>
telephonyProviderDefaults[telephonyTrunkForm.provider] || telephonyProviderDefaults.easybell
)
const telephonyRouteOptions = computed(() => telephonyExtensions.value.map((extension) => ({
label: `${extension.extension} - ${extension.targetLabel || extension.displayName || targetTypeLabel(extension.targetType)}`,
value: extension.id
})))
const targetTypeLabel = (targetType) => {
if (targetType === "team") return "Team"
if (targetType === "branch") return "Niederlassung"
return "Benutzer"
}
const loadTelephonyTrunk = async () => {
telephonyTrunkLoading.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config", {
params: { provider: telephonyTrunkForm.provider }
})
telephonyTrunkForm.provider = res?.provider || telephonyTrunkForm.provider || "easybell"
telephonyTrunkForm.enabled = Boolean(res?.enabled)
telephonyTrunkForm.registrar = res?.registrar || activeTelephonyProvider.value.registrar
telephonyTrunkForm.sipUser = res?.sipUser || ""
telephonyTrunkForm.authUser = res?.authUser || ""
telephonyTrunkForm.password = ""
telephonyTrunkForm.passwordConfigured = Boolean(res?.passwordConfigured)
telephonyTrunkForm.clearPassword = false
telephonyTrunkForm.callerId = res?.callerId || ""
telephonyTrunkForm.inboundExtension = res?.inboundExtension || "1001"
telephonyTrunkForm.defaultRouteExtensionId = res?.defaultRouteExtensionId || null
telephonyTrunkForm.outboundPrefix = res?.outboundPrefix || "0"
telephonyTrunkForm.externalSignalingAddress = res?.externalSignalingAddress || ""
telephonyTrunkForm.externalMediaAddress = res?.externalMediaAddress || ""
telephonyTrunkForm.localNetworks = res?.localNetworks || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
} catch (error) {
toast.add({ title: "Telefonie konnte nicht geladen werden", color: "error" })
} finally {
telephonyTrunkLoading.value = false
}
}
const saveTelephonyTrunk = async () => {
if (telephonyTrunkForm.enabled && (!telephonyTrunkForm.sipUser?.trim() || (!telephonyTrunkForm.password?.trim() && !telephonyTrunkForm.passwordConfigured))) {
toast.add({
title: "Trunk-Zugang unvollständig",
description: "Bitte gib mindestens SIP-ID und Kennwort an.",
color: "warning"
})
return
}
telephonyTrunkSaving.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config", {
method: "PUT",
body: {
provider: telephonyTrunkForm.provider,
enabled: telephonyTrunkForm.enabled,
registrar: telephonyTrunkForm.registrar,
sipUser: telephonyTrunkForm.sipUser,
authUser: telephonyTrunkForm.authUser,
password: telephonyTrunkForm.password,
clearPassword: telephonyTrunkForm.clearPassword,
callerId: telephonyTrunkForm.callerId,
inboundExtension: telephonyTrunkForm.inboundExtension,
defaultRouteExtensionId: telephonyTrunkForm.defaultRouteExtensionId,
outboundPrefix: telephonyTrunkForm.outboundPrefix,
externalSignalingAddress: telephonyTrunkForm.externalSignalingAddress,
externalMediaAddress: telephonyTrunkForm.externalMediaAddress,
localNetworks: telephonyTrunkForm.localNetworks
}
})
telephonyTrunkForm.password = ""
telephonyTrunkForm.passwordConfigured = Boolean(res?.passwordConfigured)
telephonyTrunkForm.clearPassword = false
toast.add({ title: "Telefonie gespeichert", color: "success" })
} catch (error) {
toast.add({
title: "Telefonie konnte nicht gespeichert werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
telephonyTrunkSaving.value = false
}
}
const loadTelephonyExtensions = async () => {
telephonyExtensionsLoading.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/extensions")
telephonyExtensions.value = res?.rows || []
} catch (error) {
toast.add({ title: "Nebenstellen konnten nicht geladen werden", color: "error" })
} finally {
telephonyExtensionsLoading.value = false
}
}
const loadTrunkStatus = async () => {
trunkStatusLoading.value = true
try {
trunkStatus.value = await useNuxtApp().$api("/api/telephony/trunk-status")
} catch (error) {
trunkStatus.value = {
reachable: false,
registered: false,
hasRegistration: false,
message: error?.data?.error || error?.message || "Status konnte nicht geladen werden."
}
} finally {
trunkStatusLoading.value = false
}
}
const applyTelephonyTrunk = async () => {
telephonyTrunkApplying.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config/apply", {
method: "POST"
})
trunkStatus.value = res?.status || trunkStatus.value
toast.add({
title: res?.warning ? "Konfiguration geschrieben" : "Telefonie angewendet",
description: res?.warning || (res?.status?.registered ? "Der SIP-Trunk ist registriert." : "Asterisk wurde neu geladen."),
color: res?.warning ? "warning" : "success"
})
} catch (error) {
toast.add({
title: "Telefonie konnte nicht angewendet werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
telephonyTrunkApplying.value = false
}
}
watch(() => telephonyTrunkForm.provider, async (provider) => {
const defaults = telephonyProviderDefaults[provider] || telephonyProviderDefaults.easybell
if (!telephonyTrunkForm.registrar || ["tel.t-online.de", "voip.easybell.de"].includes(telephonyTrunkForm.registrar)) {
telephonyTrunkForm.registrar = defaults.registrar
}
await loadTelephonyTrunk()
})
onMounted(async () => {
await Promise.all([
loadTelephonyExtensions(),
loadTelephonyTrunk(),
loadTrunkStatus()
])
})
</script>
<template>
<UDashboardNavbar title="Telefonie Einstellungen">
<template #right>
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
:loading="telephonyTrunkLoading || telephonyExtensionsLoading || trunkStatusLoading"
@click="Promise.all([loadTelephonyTrunk(), loadTelephonyExtensions(), loadTrunkStatus()])"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="mx-auto flex max-w-6xl flex-col gap-5">
<UCard>
<template #header>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-highlighted">Anschluss</h2>
<p class="mt-1 text-sm text-muted">SIP-Trunk, Standardroute und öffentliche Erreichbarkeit.</p>
</div>
<UBadge :color="telephonyTrunkForm.enabled ? 'success' : 'neutral'" variant="soft">
{{ telephonyTrunkForm.enabled ? "Aktiv" : "Inaktiv" }}
</UBadge>
</div>
</template>
<UAlert
:title="activeTelephonyProvider.title"
color="neutral"
variant="outline"
:description="activeTelephonyProvider.description"
/>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<UFormField label="Provider">
<USelectMenu
v-model="telephonyTrunkForm.provider"
:items="telephonyProviderOptions"
label-key="label"
value-key="value"
/>
</UFormField>
<UFormField label="Telefonie aktiv">
<USwitch v-model="telephonyTrunkForm.enabled" />
</UFormField>
<UFormField label="Registrar">
<UInput v-model="telephonyTrunkForm.registrar" :placeholder="activeTelephonyProvider.registrar" />
</UFormField>
<UFormField label="SIP-ID / Rufnummer">
<UInput v-model="telephonyTrunkForm.sipUser" placeholder="SIP-Benutzername" />
</UFormField>
<UFormField label="Auth-User">
<UInput v-model="telephonyTrunkForm.authUser" placeholder="Optional" />
</UFormField>
<UFormField :label="telephonyTrunkForm.passwordConfigured ? 'Kennwort ersetzen' : 'Kennwort'">
<UInput
v-model="telephonyTrunkForm.password"
type="password"
:placeholder="telephonyTrunkForm.passwordConfigured ? 'Bereits gespeichert' : 'SIP-Kennwort'"
/>
</UFormField>
<UFormField label="Gespeichertes Kennwort löschen">
<USwitch
v-model="telephonyTrunkForm.clearPassword"
:disabled="!telephonyTrunkForm.passwordConfigured"
/>
</UFormField>
<UFormField label="Absendernummer">
<UInput v-model="telephonyTrunkForm.callerId" placeholder="Optional, z. B. 49301234567" />
</UFormField>
<UFormField label="Eingehende Nebenstelle">
<UInput v-model="telephonyTrunkForm.inboundExtension" placeholder="1001" />
</UFormField>
<UFormField label="Standardroute">
<USelectMenu
v-model="telephonyTrunkForm.defaultRouteExtensionId"
:items="telephonyRouteOptions"
label-key="label"
value-key="value"
placeholder="Nebenstelle auswählen"
/>
</UFormField>
<UFormField label="Ausgehender Prefix">
<UInput v-model="telephonyTrunkForm.outboundPrefix" placeholder="0" />
</UFormField>
<UFormField label="Öffentliche Signaling-Adresse">
<UInput v-model="telephonyTrunkForm.externalSignalingAddress" placeholder="Öffentliche IP oder DNS-Name" />
</UFormField>
<UFormField label="Öffentliche Medien-Adresse">
<UInput v-model="telephonyTrunkForm.externalMediaAddress" placeholder="Leer = Signaling-Adresse" />
</UFormField>
<UFormField label="Lokale Netze">
<UInput v-model="telephonyTrunkForm.localNetworks" placeholder="172.16.0.0/12,192.168.0.0/16,10.0.0.0/8" />
</UFormField>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<UButton
icon="i-heroicons-check"
:loading="telephonyTrunkSaving"
@click="saveTelephonyTrunk"
>
Speichern
</UButton>
<UButton
icon="i-heroicons-arrow-path-rounded-square"
variant="soft"
:loading="telephonyTrunkApplying"
@click="applyTelephonyTrunk"
>
Anwenden
</UButton>
</div>
</UCard>
<UCard>
<template #header>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-highlighted">Status</h2>
<p class="mt-1 text-sm text-muted">Registrierung des externen Anschlusses.</p>
</div>
<UButton
icon="i-heroicons-signal"
variant="outline"
:loading="trunkStatusLoading"
@click="loadTrunkStatus"
>
Prüfen
</UButton>
</div>
</template>
<UAlert
:color="trunkStatus?.registered ? 'success' : (trunkStatus?.reachable ? 'warning' : 'neutral')"
:icon="trunkStatus?.registered ? 'i-heroicons-check-circle' : (trunkStatus?.reachable ? 'i-heroicons-exclamation-triangle' : 'i-heroicons-signal-slash')"
:title="trunkStatus?.registered ? 'SIP-Trunk registriert' : (trunkStatus?.hasRegistration ? 'SIP-Trunk nicht registriert' : 'Keine Trunk-Registration aktiv')"
:description="trunkStatus?.message || 'Noch kein Status geladen.'"
/>
</UCard>
<UCard>
<template #header>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-highlighted">Nebenstellen</h2>
<p class="mt-1 text-sm text-muted">Die Nebenstellen werden direkt am Benutzer, Team oder an der Niederlassung gepflegt.</p>
</div>
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
:loading="telephonyExtensionsLoading"
@click="loadTelephonyExtensions"
>
Aktualisieren
</UButton>
</div>
</template>
<div class="divide-y divide-default rounded-md border border-default">
<div
v-for="extension in telephonyExtensions"
:key="extension.id"
class="grid gap-3 p-3 md:grid-cols-[7rem_1fr_auto] md:items-center"
>
<div class="font-mono text-lg font-semibold text-highlighted">
{{ extension.extension }}
</div>
<div>
<div class="font-medium text-highlighted">
{{ extension.targetLabel || extension.displayName || targetTypeLabel(extension.targetType) }}
</div>
<div class="text-sm text-muted">
{{ targetTypeLabel(extension.targetType) }}
<span v-if="extension.targetType !== 'user'">
· Ziele: {{ extension.dialTargets?.length ? extension.dialTargets.join(", ") : "noch keine Benutzer-Nebenstelle" }}
</span>
</div>
</div>
<UBadge :color="extension.enabled ? 'success' : 'neutral'" variant="soft">
{{ extension.enabled ? "Aktiv" : "Inaktiv" }}
</UBadge>
</div>
<div v-if="!telephonyExtensions.length" class="p-4 text-sm text-muted">
Noch keine Nebenstellen angelegt.
</div>
</div>
</UCard>
</div>
</UDashboardPanelContent>
</template>

View File

@@ -562,17 +562,6 @@ const savePlanningBoardConfig = async () => {
setupPage()
onMounted(() => {
loadMcpTokens()
loadTelephonyExtensionOptions()
loadTelephonyExtensions()
loadTelephonyTrunk()
})
watch(() => telephonyTrunkForm.provider, async (provider) => {
const defaults = telephonyProviderDefaults[provider] || telephonyProviderDefaults.easybell
if (!telephonyTrunkForm.registrar || ["tel.t-online.de", "voip.easybell.de"].includes(telephonyTrunkForm.registrar)) {
telephonyTrunkForm.registrar = defaults.registrar
}
await loadTelephonyTrunk()
})
</script>
@@ -727,248 +716,14 @@ watch(() => telephonyTrunkForm.provider, async (provider) => {
</div>
<div v-else-if="item.label === 'Integrationen'">
<UCard class="mt-5">
<div class="mb-8 space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 class="text-base font-semibold text-highlighted">Telefonie-Trunk</h3>
<p class="text-sm text-muted">Konfiguriere den SIP-Trunk für externe Anrufe über Asterisk.</p>
</div>
<UBadge :color="telephonyTrunkForm.enabled ? 'success' : 'neutral'" variant="soft">
{{ telephonyTrunkForm.enabled ? "Aktiv" : "Inaktiv" }}
</UBadge>
</div>
<UAlert
:title="activeTelephonyProvider.title"
color="neutral"
variant="outline"
>
<template #description>
<p class="text-sm">
{{ activeTelephonyProvider.description }}
</p>
</template>
</UAlert>
<div class="grid gap-4 md:grid-cols-2">
<UFormField label="Provider">
<USelectMenu
v-model="telephonyTrunkForm.provider"
:items="telephonyProviderOptions"
label-key="label"
value-key="value"
/>
</UFormField>
<UFormField label="Trunk aktivieren">
<USwitch v-model="telephonyTrunkForm.enabled" />
</UFormField>
<UFormField label="Registrar">
<UInput v-model="telephonyTrunkForm.registrar" :placeholder="activeTelephonyProvider.registrar" />
</UFormField>
<UFormField label="SIP-ID / Rufnummer">
<UInput v-model="telephonyTrunkForm.sipUser" placeholder="SIP-Benutzername" />
</UFormField>
<UFormField label="Auth-User">
<UInput v-model="telephonyTrunkForm.authUser" placeholder="Optional" />
</UFormField>
<UFormField :label="telephonyTrunkForm.passwordConfigured ? 'Kennwort ersetzen' : 'Kennwort'">
<UInput
v-model="telephonyTrunkForm.password"
type="password"
:placeholder="telephonyTrunkForm.passwordConfigured ? 'Bereits gespeichert' : 'SIP-Kennwort'"
/>
</UFormField>
<UFormField label="Gespeichertes Kennwort löschen">
<USwitch
v-model="telephonyTrunkForm.clearPassword"
:disabled="!telephonyTrunkForm.passwordConfigured"
/>
</UFormField>
<UFormField label="Absendernummer">
<UInput v-model="telephonyTrunkForm.callerId" placeholder="Optional, z. B. 49301234567" />
</UFormField>
<UFormField label="Eingehende Nebenstelle">
<UInput v-model="telephonyTrunkForm.inboundExtension" placeholder="1001" />
</UFormField>
<UFormField label="Standardroute">
<USelectMenu
v-model="telephonyTrunkForm.defaultRouteExtensionId"
:items="telephonyRouteOptions"
label-key="label"
value-key="value"
placeholder="Nebenstelle auswählen"
/>
</UFormField>
<UFormField label="Ausgehender Prefix">
<UInput v-model="telephonyTrunkForm.outboundPrefix" placeholder="0" />
</UFormField>
<UFormField label="Öffentliche Signaling-Adresse">
<UInput v-model="telephonyTrunkForm.externalSignalingAddress" placeholder="Öffentliche IP oder DNS-Name" />
</UFormField>
<UFormField label="Öffentliche Medien-Adresse">
<UInput v-model="telephonyTrunkForm.externalMediaAddress" placeholder="Leer = Signaling-Adresse" />
</UFormField>
<UFormField label="Lokale Netze">
<UInput v-model="telephonyTrunkForm.localNetworks" placeholder="172.16.0.0/12,192.168.0.0/16,10.0.0.0/8" />
</UFormField>
</div>
<div class="flex flex-wrap gap-2">
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
:loading="telephonyTrunkLoading"
@click="loadTelephonyTrunk"
>
Laden
</UButton>
<UButton
icon="i-heroicons-check"
:loading="telephonyTrunkSaving"
@click="saveTelephonyTrunk"
>
Telefonie-Trunk speichern
</UButton>
<UButton
icon="i-heroicons-arrow-path-rounded-square"
color="primary"
variant="soft"
:loading="telephonyTrunkApplying"
@click="applyTelephonyTrunk"
>
In Asterisk anwenden
</UButton>
</div>
<USeparator />
<div class="space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h4 class="text-sm font-semibold text-highlighted">Nebenstellen</h4>
<p class="text-sm text-muted">Ordne Benutzer, Teams und Niederlassungen routbaren Nebenstellen zu.</p>
</div>
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
:loading="telephonyExtensionsLoading"
@click="loadTelephonyExtensions"
>
Aktualisieren
</UButton>
</div>
<div class="grid gap-4 md:grid-cols-3">
<UFormField label="Zieltyp">
<USelectMenu
v-model="telephonyExtensionForm.targetType"
:items="telephonyExtensionTargetTypes"
label-key="label"
value-key="value"
/>
</UFormField>
<UFormField v-if="telephonyExtensionForm.targetType === 'user'" label="Benutzer">
<USelectMenu
v-model="telephonyExtensionForm.targetUserId"
:items="activeTelephonyTargetOptions"
label-key="label"
value-key="id"
placeholder="Benutzer auswählen"
/>
</UFormField>
<UFormField v-else-if="telephonyExtensionForm.targetType === 'team'" label="Team">
<USelectMenu
v-model="telephonyExtensionForm.targetTeamId"
:items="activeTelephonyTargetOptions"
label-key="label"
value-key="id"
placeholder="Team auswählen"
/>
</UFormField>
<UFormField v-else label="Niederlassung">
<USelectMenu
v-model="telephonyExtensionForm.targetBranchId"
:items="activeTelephonyTargetOptions"
label-key="label"
value-key="id"
placeholder="Niederlassung auswählen"
/>
</UFormField>
<UFormField label="Nebenstelle">
<UInput v-model="telephonyExtensionForm.extension" placeholder="1001" />
</UFormField>
<UFormField label="Anzeigename">
<UInput v-model="telephonyExtensionForm.displayName" placeholder="Optional" />
</UFormField>
<UFormField label="SIP-Benutzername">
<UInput v-model="telephonyExtensionForm.sipUsername" placeholder="Leer = Nebenstelle" />
</UFormField>
<UFormField label="SIP-Kennwort">
<UInput v-model="telephonyExtensionForm.sipPassword" type="password" placeholder="Leer = automatisch" />
</UFormField>
<UFormField label="Aktiv">
<USwitch v-model="telephonyExtensionForm.enabled" />
</UFormField>
</div>
<div class="flex flex-wrap gap-2">
<UButton
icon="i-heroicons-check"
:loading="telephonyExtensionSaving"
@click="saveTelephonyExtension"
>
{{ telephonyExtensionForm.id ? "Nebenstelle speichern" : "Nebenstelle anlegen" }}
</UButton>
<UButton
v-if="telephonyExtensionForm.id"
variant="outline"
@click="resetTelephonyExtensionForm"
>
Abbrechen
</UButton>
</div>
<div class="divide-y divide-default rounded-md border border-default">
<div
v-for="extension in telephonyExtensions"
:key="extension.id"
class="flex flex-col gap-3 p-3 md:flex-row md:items-center md:justify-between"
>
<div>
<div class="font-medium text-highlighted">
{{ extension.extension }} - {{ extension.displayName || extension.targetType }}
</div>
<div class="text-sm text-muted">
{{ extension.targetType }} · Ziele: {{ extension.dialTargets?.length ? extension.dialTargets.join(", ") : "noch keine Benutzer-Nebenstelle" }}
</div>
</div>
<div class="flex gap-2">
<UButton
icon="i-heroicons-pencil-square"
variant="outline"
@click="editTelephonyExtension(extension)"
>
Bearbeiten
</UButton>
<UButton
icon="i-heroicons-trash"
color="error"
variant="soft"
:loading="telephonyExtensionDeletingId === extension.id"
@click="deleteTelephonyExtension(extension)"
>
Löschen
</UButton>
</div>
</div>
<div v-if="!telephonyExtensions.length" class="p-3 text-sm text-muted">
Noch keine Nebenstellen angelegt.
</div>
</div>
</div>
</div>
<USeparator class="mb-8" />
<UAlert
class="mb-8"
color="neutral"
icon="i-heroicons-phone"
title="Telefonie ist jetzt eine eigene Einstellungsseite"
description="SIP-Trunk, Standardroute und Nebenstellenübersicht findest du unter Einstellungen > Telefonie."
:actions="[{ label: 'Telefonie öffnen', to: '/settings/telephony', icon: 'i-heroicons-arrow-top-right-on-square' }]"
/>
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">

View File

@@ -445,6 +445,17 @@ onMounted(async () => {
</UFormField>
</UForm>
</UCard>
<TelephonyExtensionField
v-if="!pending && profile"
class="mt-3"
target-type="user"
:target-id="profile.user_id"
:display-name="profile.full_name || `${profile.first_name || ''} ${profile.last_name || ''}`.trim()"
title="Telefonie"
description="Lege fest, unter welcher Nebenstelle dieser Benutzer erreichbar ist."
/>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Vertragsinformationen" />