KI-AGENT: Telefonie Nebenstellen in Einstellungen integrieren
This commit is contained in:
@@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"
|
|||||||
import net from "node:net"
|
import net from "node:net"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { randomBytes } from "node:crypto"
|
import { randomBytes } from "node:crypto"
|
||||||
import { and, desc, eq, inArray } from "drizzle-orm"
|
import { and, desc, eq, inArray, ne } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
authProfileBranches,
|
authProfileBranches,
|
||||||
authProfiles,
|
authProfiles,
|
||||||
@@ -25,21 +25,15 @@ const telephonyEnabled = () =>
|
|||||||
envFlag(process.env.TELEPHONY_ENABLED, process.env.NODE_ENV !== "production")
|
envFlag(process.env.TELEPHONY_ENABLED, process.env.NODE_ENV !== "production")
|
||||||
|
|
||||||
const asteriskHttpStatusUrl = () =>
|
const asteriskHttpStatusUrl = () =>
|
||||||
process.env.TELEPHONY_ASTERISK_HTTP_URL || "http://asterisk-dev:8088/ws"
|
process.env.TELEPHONY_ASTERISK_HTTP_URL || ""
|
||||||
|
|
||||||
const asteriskHttpStatusUrls = () => {
|
const asteriskHttpStatusUrls = () => {
|
||||||
const configuredUrl = asteriskHttpStatusUrl()
|
const configuredUrl = asteriskHttpStatusUrl()
|
||||||
return Array.from(new Set([
|
return configuredUrl ? [configuredUrl] : []
|
||||||
configuredUrl,
|
|
||||||
"http://asterisk-dev:8088/ws",
|
|
||||||
"http://host.docker.internal:8088/ws",
|
|
||||||
`http://127.0.0.1:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`,
|
|
||||||
`http://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`,
|
|
||||||
]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAsteriskWsUrl = () =>
|
const publicAsteriskWsUrl = () =>
|
||||||
process.env.TELEPHONY_ASTERISK_WS_URL || `ws://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`
|
process.env.TELEPHONY_ASTERISK_WS_URL || ""
|
||||||
|
|
||||||
const defaultAsteriskGeneratedDir = () => {
|
const defaultAsteriskGeneratedDir = () => {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
@@ -50,7 +44,7 @@ const asteriskGeneratedDir = () =>
|
|||||||
process.env.TELEPHONY_ASTERISK_GENERATED_DIR || defaultAsteriskGeneratedDir()
|
process.env.TELEPHONY_ASTERISK_GENERATED_DIR || defaultAsteriskGeneratedDir()
|
||||||
|
|
||||||
const defaultAsteriskAmiHost = () =>
|
const defaultAsteriskAmiHost = () =>
|
||||||
process.env.NODE_ENV === "production" ? "asterisk-dev" : "127.0.0.1"
|
process.env.NODE_ENV === "production" ? "" : "127.0.0.1"
|
||||||
|
|
||||||
const asteriskAmiConfig = () => ({
|
const asteriskAmiConfig = () => ({
|
||||||
host: process.env.TELEPHONY_ASTERISK_AMI_HOST || defaultAsteriskAmiHost(),
|
host: process.env.TELEPHONY_ASTERISK_AMI_HOST || defaultAsteriskAmiHost(),
|
||||||
@@ -62,19 +56,6 @@ const asteriskAmiConfig = () => ({
|
|||||||
const sipDomain = () =>
|
const sipDomain = () =>
|
||||||
process.env.TELEPHONY_SIP_DOMAIN || "localhost"
|
process.env.TELEPHONY_SIP_DOMAIN || "localhost"
|
||||||
|
|
||||||
const testAccounts = () => [
|
|
||||||
{
|
|
||||||
extension: process.env.TELEPHONY_TEST_EXTENSION || "1001",
|
|
||||||
password: process.env.TELEPHONY_TEST_PASSWORD || "fedeo-test-1001",
|
|
||||||
displayName: "FEDEO Test 1001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extension: process.env.TELEPHONY_TEST_EXTENSION_2 || "1002",
|
|
||||||
password: process.env.TELEPHONY_TEST_PASSWORD_2 || "fedeo-test-1002",
|
|
||||||
displayName: "FEDEO Test 1002",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const trunkProviders = {
|
const trunkProviders = {
|
||||||
telekom: {
|
telekom: {
|
||||||
key: "telekom",
|
key: "telekom",
|
||||||
@@ -498,6 +479,9 @@ const writeAsteriskTrunkConfig = async (trunk: any, extensions: any[] = [], dial
|
|||||||
|
|
||||||
const runAsteriskAmiCommand = async (command: string, timeoutMs = 5000) => {
|
const runAsteriskAmiCommand = async (command: string, timeoutMs = 5000) => {
|
||||||
const config = asteriskAmiConfig()
|
const config = asteriskAmiConfig()
|
||||||
|
if (!config.host) {
|
||||||
|
throw new Error("Asterisk-AMI ist nicht konfiguriert.")
|
||||||
|
}
|
||||||
|
|
||||||
return await new Promise<{ command: string, ok: boolean, raw: string }>((resolve, reject) => {
|
return await new Promise<{ command: string, ok: boolean, raw: string }>((resolve, reject) => {
|
||||||
const socket = net.createConnection(config.port, config.host)
|
const socket = net.createConnection(config.port, config.host)
|
||||||
@@ -708,6 +692,59 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
updatedAt: extension.updatedAt,
|
updatedAt: extension.updatedAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const extensionTargetWhere = (tenantId: number, targetType: string, targetId: string | number | null) => {
|
||||||
|
if (targetType === "team") {
|
||||||
|
return and(
|
||||||
|
eq(telephonyExtensions.tenantId, tenantId),
|
||||||
|
eq(telephonyExtensions.targetType, "team"),
|
||||||
|
eq(telephonyExtensions.targetTeamId, Number(targetId))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === "branch") {
|
||||||
|
return and(
|
||||||
|
eq(telephonyExtensions.tenantId, tenantId),
|
||||||
|
eq(telephonyExtensions.targetType, "branch"),
|
||||||
|
eq(telephonyExtensions.targetBranchId, Number(targetId))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return and(
|
||||||
|
eq(telephonyExtensions.tenantId, tenantId),
|
||||||
|
eq(telephonyExtensions.targetType, "user"),
|
||||||
|
eq(telephonyExtensions.targetUserId, String(targetId))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveExtensionTargetId = (targetType: string, body: any) => {
|
||||||
|
if (targetType === "team") return bodyNumber(body, "targetTeamId") ?? bodyNumber(body, "targetId")
|
||||||
|
if (targetType === "branch") return bodyNumber(body, "targetBranchId") ?? bodyNumber(body, "targetId")
|
||||||
|
return bodyString(body, "targetUserId") || bodyString(body, "targetId")
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateExtensionDuplicate = async (
|
||||||
|
tenantId: number,
|
||||||
|
extension: string,
|
||||||
|
currentExtensionId: string | null = null
|
||||||
|
) => {
|
||||||
|
const conditions = [
|
||||||
|
eq(telephonyExtensions.tenantId, tenantId),
|
||||||
|
eq(telephonyExtensions.extension, extension),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (currentExtensionId) {
|
||||||
|
conditions.push(ne(telephonyExtensions.id, currentExtensionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: telephonyExtensions.id })
|
||||||
|
.from(telephonyExtensions)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return existing[0] ? "Diese Nebenstelle ist bereits vergeben." : null
|
||||||
|
}
|
||||||
|
|
||||||
const externalTelephonyConfig = async (tenantId: number | null) => {
|
const externalTelephonyConfig = async (tenantId: number | null) => {
|
||||||
const trunk = await loadActiveTenantTrunk(tenantId)
|
const trunk = await loadActiveTenantTrunk(tenantId)
|
||||||
if (trunk) {
|
if (trunk) {
|
||||||
@@ -731,14 +768,34 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
return envExternalTelephonyConfig()
|
return envExternalTelephonyConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadUserSipAccounts = async (tenantId: number | null, userId: string | null | undefined) => {
|
||||||
|
if (!tenantId || !userId) return []
|
||||||
|
|
||||||
|
const extensions = await server.db
|
||||||
|
.select()
|
||||||
|
.from(telephonyExtensions)
|
||||||
|
.where(and(
|
||||||
|
eq(telephonyExtensions.tenantId, tenantId),
|
||||||
|
eq(telephonyExtensions.targetType, "user"),
|
||||||
|
eq(telephonyExtensions.targetUserId, userId),
|
||||||
|
eq(telephonyExtensions.enabled, true)
|
||||||
|
))
|
||||||
|
|
||||||
|
return extensions.map((extension) => ({
|
||||||
|
extension: extension.extension,
|
||||||
|
password: extension.sipPassword,
|
||||||
|
displayName: extension.displayName || `Nebenstelle ${extension.extension}`,
|
||||||
|
})).filter((account) => account.extension && account.password)
|
||||||
|
}
|
||||||
|
|
||||||
server.get("/telephony/config", async (req) => ({
|
server.get("/telephony/config", async (req) => ({
|
||||||
enabled: telephonyEnabled(),
|
enabled: telephonyEnabled(),
|
||||||
provider: "asterisk",
|
provider: "asterisk",
|
||||||
mode: "local-test",
|
mode: "asterisk",
|
||||||
sipDomain: sipDomain(),
|
sipDomain: sipDomain(),
|
||||||
sipWebSocketUrl: publicAsteriskWsUrl(),
|
sipWebSocketUrl: publicAsteriskWsUrl(),
|
||||||
echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600",
|
echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600",
|
||||||
testAccounts: testAccounts(),
|
accounts: await loadUserSipAccounts(req.user?.tenant_id || null, req.user?.user_id),
|
||||||
external: await externalTelephonyConfig(req.user?.tenant_id || null),
|
external: await externalTelephonyConfig(req.user?.tenant_id || null),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -787,7 +844,9 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
reachable: false,
|
reachable: false,
|
||||||
statusUrl: urls[0],
|
statusUrl: urls[0],
|
||||||
attempts,
|
attempts,
|
||||||
message: lastError?.name === "AbortError"
|
message: !urls.length
|
||||||
|
? "Asterisk-Status-URL ist nicht konfiguriert."
|
||||||
|
: lastError?.name === "AbortError"
|
||||||
? "Asterisk-Statusabfrage ist abgelaufen."
|
? "Asterisk-Statusabfrage ist abgelaufen."
|
||||||
: (lastError?.message || "Asterisk ist nicht erreichbar."),
|
: (lastError?.message || "Asterisk ist nicht erreichbar."),
|
||||||
}
|
}
|
||||||
@@ -1002,17 +1061,166 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
const tenantId = requireTenant(req.user.tenant_id)
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
const extensions = await loadTenantExtensions(tenantId)
|
const extensions = await loadTenantExtensions(tenantId)
|
||||||
const dialTargetsById = await loadExtensionDialTargets(tenantId, extensions)
|
const dialTargetsById = await loadExtensionDialTargets(tenantId, extensions)
|
||||||
|
const options = await (async () => {
|
||||||
|
const tenantUsers = await server.db
|
||||||
|
.select({
|
||||||
|
id: authUsers.id,
|
||||||
|
email: authUsers.email,
|
||||||
|
firstName: authProfiles.first_name,
|
||||||
|
lastName: authProfiles.last_name,
|
||||||
|
})
|
||||||
|
.from(authProfiles)
|
||||||
|
.innerJoin(authUsers, eq(authUsers.id, authProfiles.user_id))
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
eq(authProfiles.active, true)
|
||||||
|
))
|
||||||
|
|
||||||
|
const tenantTeams = await server.db
|
||||||
|
.select({ id: teams.id, name: teams.name })
|
||||||
|
.from(teams)
|
||||||
|
.where(and(eq(teams.tenant, tenantId), eq(teams.archived, false)))
|
||||||
|
|
||||||
|
const tenantBranches = await server.db
|
||||||
|
.select({ id: branches.id, name: branches.name, number: branches.number })
|
||||||
|
.from(branches)
|
||||||
|
.where(and(eq(branches.tenant, tenantId), eq(branches.archived, false)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: new Map(tenantUsers.map((user) => [
|
||||||
|
user.id,
|
||||||
|
[user.firstName, user.lastName].filter(Boolean).join(" ") || user.email,
|
||||||
|
])),
|
||||||
|
teams: new Map(tenantTeams.map((team) => [team.id, team.name])),
|
||||||
|
branches: new Map(tenantBranches.map((branch) => [
|
||||||
|
branch.id,
|
||||||
|
branch.number ? `${branch.number} - ${branch.name}` : branch.name,
|
||||||
|
])),
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: extensions
|
rows: extensions
|
||||||
.sort((a, b) => a.extension.localeCompare(b.extension, "de"))
|
.sort((a, b) => a.extension.localeCompare(b.extension, "de"))
|
||||||
.map((extension) => ({
|
.map((extension) => ({
|
||||||
...sanitizeExtension(extension),
|
...sanitizeExtension(extension),
|
||||||
|
targetLabel: extension.targetType === "team"
|
||||||
|
? options.teams.get(extension.targetTeamId)
|
||||||
|
: extension.targetType === "branch"
|
||||||
|
? options.branches.get(extension.targetBranchId)
|
||||||
|
: options.users.get(extension.targetUserId),
|
||||||
dialTargets: dialTargetsById.get(extension.id) || [],
|
dialTargets: dialTargetsById.get(extension.id) || [],
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.get("/telephony/extensions/target", async (req) => {
|
||||||
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
|
const query = req.query as { targetType?: string; targetId?: string }
|
||||||
|
const targetType = normalizeExtensionTargetType(query.targetType)
|
||||||
|
const targetId = targetType === "user" ? query.targetId : Number(query.targetId)
|
||||||
|
|
||||||
|
if (!targetId || (targetType !== "user" && !Number.isFinite(Number(targetId)))) {
|
||||||
|
return { extension: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await server.db
|
||||||
|
.select()
|
||||||
|
.from(telephonyExtensions)
|
||||||
|
.where(extensionTargetWhere(tenantId, targetType, targetId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return { extension: existing[0] ? sanitizeExtension(existing[0]) : null }
|
||||||
|
})
|
||||||
|
|
||||||
|
server.put("/telephony/extensions/target", async (req, reply) => {
|
||||||
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
|
const body = (req.body || {}) as any
|
||||||
|
const targetType = normalizeExtensionTargetType(bodyString(body, "targetType"))
|
||||||
|
const targetId = resolveExtensionTargetId(targetType, body)
|
||||||
|
const extension = normalizeExtension(bodyString(body, "extension"))
|
||||||
|
|
||||||
|
if (!targetId) {
|
||||||
|
return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
return reply.code(400).send({ error: "Nebenstelle ist erforderlich." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await server.db
|
||||||
|
.select()
|
||||||
|
.from(telephonyExtensions)
|
||||||
|
.where(extensionTargetWhere(tenantId, targetType, targetId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const duplicateError = await validateExtensionDuplicate(tenantId, extension, existing[0]?.id || null)
|
||||||
|
if (duplicateError) {
|
||||||
|
return reply.code(409).send({ error: duplicateError })
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = bodyString(body, "displayName")
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
const [updated] = await server.db
|
||||||
|
.update(telephonyExtensions)
|
||||||
|
.set({
|
||||||
|
extension,
|
||||||
|
displayName,
|
||||||
|
sipUsername: extension,
|
||||||
|
enabled: body?.enabled !== false,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: req.user.user_id,
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(telephonyExtensions.tenantId, tenantId),
|
||||||
|
eq(telephonyExtensions.id, existing[0].id)
|
||||||
|
))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return sanitizeExtension(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await server.db
|
||||||
|
.insert(telephonyExtensions)
|
||||||
|
.values({
|
||||||
|
tenantId,
|
||||||
|
targetType,
|
||||||
|
targetUserId: targetType === "user" ? String(targetId) : null,
|
||||||
|
targetTeamId: targetType === "team" ? Number(targetId) : null,
|
||||||
|
targetBranchId: targetType === "branch" ? Number(targetId) : null,
|
||||||
|
extension,
|
||||||
|
displayName,
|
||||||
|
sipUsername: extension,
|
||||||
|
sipPassword: generateSipPassword(),
|
||||||
|
enabled: body?.enabled !== false,
|
||||||
|
createdBy: req.user.user_id,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: req.user.user_id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return reply.code(201).send(sanitizeExtension(created))
|
||||||
|
})
|
||||||
|
|
||||||
|
server.delete("/telephony/extensions/target", async (req, reply) => {
|
||||||
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
|
const query = req.query as { targetType?: string; targetId?: string }
|
||||||
|
const targetType = normalizeExtensionTargetType(query.targetType)
|
||||||
|
const targetId = targetType === "user" ? query.targetId : Number(query.targetId)
|
||||||
|
|
||||||
|
if (!targetId || (targetType !== "user" && !Number.isFinite(Number(targetId)))) {
|
||||||
|
return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." })
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.delete(telephonyExtensions)
|
||||||
|
.where(extensionTargetWhere(tenantId, targetType, targetId))
|
||||||
|
|
||||||
|
return { deleted: true }
|
||||||
|
})
|
||||||
|
|
||||||
server.post("/telephony/extensions", async (req, reply) => {
|
server.post("/telephony/extensions", async (req, reply) => {
|
||||||
const tenantId = requireTenant(req.user.tenant_id)
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
const body = (req.body || {}) as any
|
const body = (req.body || {}) as any
|
||||||
@@ -1023,6 +1231,11 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: "Nebenstelle ist erforderlich." })
|
return reply.code(400).send({ error: "Nebenstelle ist erforderlich." })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateError = await validateExtensionDuplicate(tenantId, extension)
|
||||||
|
if (duplicateError) {
|
||||||
|
return reply.code(409).send({ error: duplicateError })
|
||||||
|
}
|
||||||
|
|
||||||
const targetUserId = targetType === "user" ? bodyString(body, "targetUserId") : null
|
const targetUserId = targetType === "user" ? bodyString(body, "targetUserId") : null
|
||||||
const targetTeamId = targetType === "team" ? bodyNumber(body, "targetTeamId") : null
|
const targetTeamId = targetType === "team" ? bodyNumber(body, "targetTeamId") : null
|
||||||
const targetBranchId = targetType === "branch" ? bodyNumber(body, "targetBranchId") : null
|
const targetBranchId = targetType === "branch" ? bodyNumber(body, "targetBranchId") : null
|
||||||
@@ -1083,6 +1296,11 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: "Nebenstelle nicht gefunden." })
|
return reply.code(404).send({ error: "Nebenstelle nicht gefunden." })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateError = await validateExtensionDuplicate(tenantId, extension, params.id)
|
||||||
|
if (duplicateError) {
|
||||||
|
return reply.code(409).send({ error: duplicateError })
|
||||||
|
}
|
||||||
|
|
||||||
const sipPassword = bodyString(body, "sipPassword")
|
const sipPassword = bodyString(body, "sipPassword")
|
||||||
const [updated] = await server.db
|
const [updated] = await server.db
|
||||||
.update(telephonyExtensions)
|
.update(telephonyExtensions)
|
||||||
|
|||||||
@@ -58,19 +58,15 @@ services:
|
|||||||
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
|
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
|
||||||
- NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
- NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
||||||
- TELEPHONY_ENABLED=${TELEPHONY_ENABLED:-false}
|
- TELEPHONY_ENABLED=${TELEPHONY_ENABLED:-false}
|
||||||
- TELEPHONY_ASTERISK_HTTP_URL=${TELEPHONY_ASTERISK_HTTP_URL:-http://asterisk-dev:8088/ws}
|
- TELEPHONY_ASTERISK_HTTP_URL=${TELEPHONY_ASTERISK_HTTP_URL:-}
|
||||||
- TELEPHONY_ASTERISK_WS_URL=${TELEPHONY_ASTERISK_WS_URL:-ws://localhost:8088/ws}
|
- TELEPHONY_ASTERISK_WS_URL=${TELEPHONY_ASTERISK_WS_URL:-}
|
||||||
- TELEPHONY_SIP_DOMAIN=${TELEPHONY_SIP_DOMAIN:-localhost}
|
- TELEPHONY_SIP_DOMAIN=${TELEPHONY_SIP_DOMAIN:-localhost}
|
||||||
- TELEPHONY_TEST_EXTENSION=${TELEPHONY_TEST_EXTENSION:-1001}
|
|
||||||
- TELEPHONY_TEST_PASSWORD=${TELEPHONY_TEST_PASSWORD:-fedeo-test-1001}
|
|
||||||
- TELEPHONY_TEST_EXTENSION_2=${TELEPHONY_TEST_EXTENSION_2:-1002}
|
|
||||||
- TELEPHONY_TEST_PASSWORD_2=${TELEPHONY_TEST_PASSWORD_2:-fedeo-test-1002}
|
|
||||||
- TELEPHONY_ECHO_EXTENSION=${TELEPHONY_ECHO_EXTENSION:-600}
|
- TELEPHONY_ECHO_EXTENSION=${TELEPHONY_ECHO_EXTENSION:-600}
|
||||||
- TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-}
|
- TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-}
|
||||||
- TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false}
|
- TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false}
|
||||||
- TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001}
|
- TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001}
|
||||||
- TELEPHONY_ASTERISK_GENERATED_DIR=${TELEPHONY_ASTERISK_GENERATED_DIR:-/var/lib/fedeo/asterisk/generated}
|
- TELEPHONY_ASTERISK_GENERATED_DIR=${TELEPHONY_ASTERISK_GENERATED_DIR:-/var/lib/fedeo/asterisk/generated}
|
||||||
- TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-asterisk-dev}
|
- TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-}
|
||||||
- TELEPHONY_ASTERISK_AMI_PORT=${TELEPHONY_ASTERISK_AMI_PORT:-5038}
|
- TELEPHONY_ASTERISK_AMI_PORT=${TELEPHONY_ASTERISK_AMI_PORT:-5038}
|
||||||
- TELEPHONY_ASTERISK_AMI_USER=${TELEPHONY_ASTERISK_AMI_USER:-fedeo}
|
- TELEPHONY_ASTERISK_AMI_USER=${TELEPHONY_ASTERISK_AMI_USER:-fedeo}
|
||||||
- TELEPHONY_ASTERISK_AMI_PASSWORD=${TELEPHONY_ASTERISK_AMI_PASSWORD:-fedeo-ami-dev}
|
- TELEPHONY_ASTERISK_AMI_PASSWORD=${TELEPHONY_ASTERISK_AMI_PASSWORD:-fedeo-ami-dev}
|
||||||
@@ -111,39 +107,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
|
|
||||||
asterisk-dev:
|
|
||||||
image: ${ASTERISK_IMAGE:-andrius/asterisk:20}
|
|
||||||
restart: unless-stopped
|
|
||||||
profiles:
|
|
||||||
- telephony-dev
|
|
||||||
environment:
|
|
||||||
- TELEPHONY_TELEKOM_ENABLED=${TELEPHONY_TELEKOM_ENABLED:-false}
|
|
||||||
- TELEPHONY_TELEKOM_REGISTRAR=${TELEPHONY_TELEKOM_REGISTRAR:-tel.t-online.de}
|
|
||||||
- TELEPHONY_TELEKOM_SIP_USER=${TELEPHONY_TELEKOM_SIP_USER:-}
|
|
||||||
- TELEPHONY_TELEKOM_AUTH_USER=${TELEPHONY_TELEKOM_AUTH_USER:-}
|
|
||||||
- TELEPHONY_TELEKOM_PASSWORD=${TELEPHONY_TELEKOM_PASSWORD:-}
|
|
||||||
- TELEPHONY_TELEKOM_CALLER_ID=${TELEPHONY_TELEKOM_CALLER_ID:-}
|
|
||||||
- TELEPHONY_TELEKOM_INBOUND_EXTENSION=${TELEPHONY_TELEKOM_INBOUND_EXTENSION:-1001}
|
|
||||||
- TELEPHONY_TELEKOM_OUTBOUND_PREFIX=${TELEPHONY_TELEKOM_OUTBOUND_PREFIX:-0}
|
|
||||||
- TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS=${TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS:-}
|
|
||||||
- TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS=${TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS:-}
|
|
||||||
- ASTERISK_GENERATED_DIR=/etc/asterisk/generated
|
|
||||||
command:
|
|
||||||
- /bin/sh
|
|
||||||
- -c
|
|
||||||
- /usr/local/bin/render-asterisk-config.sh && asterisk -f
|
|
||||||
volumes:
|
|
||||||
- ./telephony/asterisk:/etc/asterisk
|
|
||||||
- ./telephony/generated:/etc/asterisk/generated
|
|
||||||
- ./telephony/render-asterisk-config.sh:/usr/local/bin/render-asterisk-config.sh:ro
|
|
||||||
ports:
|
|
||||||
- "${TELEPHONY_DEV_WS_PORT:-8088}:8088"
|
|
||||||
- "${TELEPHONY_DEV_AMI_PORT:-5038}:5038"
|
|
||||||
- "${TELEPHONY_DEV_SIP_PORT:-5060}:5060/udp"
|
|
||||||
- "${TELEPHONY_DEV_RTP_MIN_PORT:-10000}-${TELEPHONY_DEV_RTP_MAX_PORT:-10100}:10000-10100/udp"
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
|
|
||||||
matrix-db:
|
matrix-db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -400,6 +400,32 @@ const canArchiveItem = computed(() => {
|
|||||||
return true
|
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 () => {
|
const createItem = async () => {
|
||||||
let ret = null
|
let ret = null
|
||||||
|
|
||||||
@@ -1036,6 +1062,16 @@ const updateItem = async () => {
|
|||||||
</InputGroup>
|
</InputGroup>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</UForm>
|
</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>
|
</UDashboardPanelContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -85,11 +85,6 @@ const links = computed(() => {
|
|||||||
to: "/communication/phone",
|
to: "/communication/phone",
|
||||||
icon: "i-heroicons-phone"
|
icon: "i-heroicons-phone"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Telefonie Setup",
|
|
||||||
to: "/communication/phone-setup",
|
|
||||||
icon: "i-heroicons-cog-6-tooth"
|
|
||||||
},
|
|
||||||
featureEnabled("helpdesk") ? {
|
featureEnabled("helpdesk") ? {
|
||||||
label: "Helpdesk",
|
label: "Helpdesk",
|
||||||
to: "/helpdesk",
|
to: "/helpdesk",
|
||||||
@@ -342,6 +337,11 @@ const links = computed(() => {
|
|||||||
to: "/settings/tenant",
|
to: "/settings/tenant",
|
||||||
icon: "i-heroicons-building-office",
|
icon: "i-heroicons-building-office",
|
||||||
} : null,
|
} : null,
|
||||||
|
{
|
||||||
|
label: "Telefonie",
|
||||||
|
to: "/settings/telephony",
|
||||||
|
icon: "i-heroicons-phone",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Matrix-Setup",
|
label: "Matrix-Setup",
|
||||||
to: "/communication",
|
to: "/communication",
|
||||||
|
|||||||
197
frontend/components/TelephonyExtensionField.vue
Normal file
197
frontend/components/TelephonyExtensionField.vue
Normal 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>
|
||||||
@@ -40,8 +40,8 @@ export const useTelephonySoftphone = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedAccount = computed(() =>
|
const selectedAccount = computed(() =>
|
||||||
(config.value?.testAccounts || []).find((account) => account.extension === selectedExtension.value)
|
(config.value?.accounts || []).find((account) => account.extension === selectedExtension.value)
|
||||||
|| config.value?.testAccounts?.[0]
|
|| (config.value?.accounts || [])[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
const canRegisterSip = computed(() =>
|
const canRegisterSip = computed(() =>
|
||||||
@@ -324,8 +324,9 @@ export const useTelephonySoftphone = () => {
|
|||||||
status.value = statusRes
|
status.value = statusRes
|
||||||
lastUpdated.value = new Date()
|
lastUpdated.value = new Date()
|
||||||
|
|
||||||
if (!selectedAccount.value && configRes?.testAccounts?.length) {
|
const accounts = configRes?.accounts || []
|
||||||
selectedExtension.value = configRes.testAccounts[0].extension
|
if (!selectedAccount.value && accounts.length) {
|
||||||
|
selectedExtension.value = accounts[0].extension
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadCallHistory()
|
await loadCallHistory()
|
||||||
|
|||||||
@@ -1,451 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const toast = useToast()
|
await navigateTo("/settings/telephony", { replace: true })
|
||||||
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()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ onMounted(loadTelephony)
|
|||||||
<label class="text-sm font-medium text-gray-700">Nebenstelle</label>
|
<label class="text-sm font-medium text-gray-700">Nebenstelle</label>
|
||||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
v-for="account in config?.testAccounts || []"
|
v-for="account in config?.accounts || []"
|
||||||
:key="account.extension"
|
:key="account.extension"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border px-4 py-3 text-left transition"
|
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>
|
<span class="mt-1 block text-xs opacity-75">{{ account.displayName }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
|||||||
406
frontend/pages/settings/telephony.vue
Normal file
406
frontend/pages/settings/telephony.vue
Normal 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>
|
||||||
@@ -562,17 +562,6 @@ const savePlanningBoardConfig = async () => {
|
|||||||
setupPage()
|
setupPage()
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMcpTokens()
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -727,248 +716,14 @@ watch(() => telephonyTrunkForm.provider, async (provider) => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="item.label === 'Integrationen'">
|
<div v-else-if="item.label === 'Integrationen'">
|
||||||
<UCard class="mt-5">
|
<UCard class="mt-5">
|
||||||
<div class="mb-8 space-y-6">
|
<UAlert
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
class="mb-8"
|
||||||
<div>
|
color="neutral"
|
||||||
<h3 class="text-base font-semibold text-highlighted">Telefonie-Trunk</h3>
|
icon="i-heroicons-phone"
|
||||||
<p class="text-sm text-muted">Konfiguriere den SIP-Trunk für externe Anrufe über Asterisk.</p>
|
title="Telefonie ist jetzt eine eigene Einstellungsseite"
|
||||||
</div>
|
description="SIP-Trunk, Standardroute und Nebenstellenübersicht findest du unter Einstellungen > Telefonie."
|
||||||
<UBadge :color="telephonyTrunkForm.enabled ? 'success' : 'neutral'" variant="soft">
|
:actions="[{ label: 'Telefonie öffnen', to: '/settings/telephony', icon: 'i-heroicons-arrow-top-right-on-square' }]"
|
||||||
{{ 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" />
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
|||||||
@@ -445,6 +445,17 @@ onMounted(async () => {
|
|||||||
</UFormField>
|
</UFormField>
|
||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</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">
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
<USeparator label="Vertragsinformationen" />
|
<USeparator label="Vertragsinformationen" />
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ TELEPHONY_ASTERISK_AMI_HOST=127.0.0.1
|
|||||||
TELEPHONY_ASTERISK_AMI_PORT=5038
|
TELEPHONY_ASTERISK_AMI_PORT=5038
|
||||||
TELEPHONY_ASTERISK_AMI_USER=fedeo
|
TELEPHONY_ASTERISK_AMI_USER=fedeo
|
||||||
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
|
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
|
||||||
TELEPHONY_TEST_EXTENSION=1001
|
|
||||||
TELEPHONY_TEST_PASSWORD=fedeo-test-1001
|
|
||||||
TELEPHONY_TEST_EXTENSION_2=1002
|
|
||||||
TELEPHONY_TEST_PASSWORD_2=fedeo-test-1002
|
|
||||||
TELEPHONY_EXTERNAL_PROVIDER=easybell
|
TELEPHONY_EXTERNAL_PROVIDER=easybell
|
||||||
TELEPHONY_EXTERNAL_ENABLED=true
|
TELEPHONY_EXTERNAL_ENABLED=true
|
||||||
TELEPHONY_EXTERNAL_INBOUND_EXTENSION=1001
|
TELEPHONY_EXTERNAL_INBOUND_EXTENSION=1001
|
||||||
|
|||||||
Reference in New Issue
Block a user