KI-AGENT: Anrufbuttons und Telefoniejournal erweitern

This commit is contained in:
2026-05-22 16:14:07 +02:00
parent 3594dc69e8
commit 8a2429827c
5 changed files with 210 additions and 19 deletions

View File

@@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"
import net from "node:net"
import path from "node:path"
import { randomBytes } from "node:crypto"
import { and, desc, eq, inArray, ne } from "drizzle-orm"
import { and, desc, eq, ilike, inArray, ne, or } from "drizzle-orm"
import {
authProfileBranches,
authProfiles,
@@ -1347,15 +1347,44 @@ export default async function telephonyRoutes(server: FastifyInstance) {
server.get("/telephony/calls", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const query = req.query as {
limit?: string
direction?: string
status?: string
search?: string
localExtension?: string
}
const limit = Math.min(
Math.max(Number((req.query as { limit?: string })?.limit || 25), 1),
Math.max(Number(query?.limit || 25), 1),
100
)
const filters = [eq(telephonyCalls.tenantId, tenantId)]
if (query.direction === "incoming" || query.direction === "outgoing") {
filters.push(eq(telephonyCalls.direction, query.direction))
}
if (query.status && query.status !== "all") {
filters.push(eq(telephonyCalls.status, query.status))
}
if (query.localExtension) {
filters.push(eq(telephonyCalls.localExtension, query.localExtension))
}
if (query.search?.trim()) {
const search = `%${query.search.trim()}%`
filters.push(or(
ilike(telephonyCalls.remoteNumber, search),
ilike(telephonyCalls.remoteDisplayName, search),
ilike(telephonyCalls.localExtension, search)
)!)
}
return await server.db
.select()
.from(telephonyCalls)
.where(eq(telephonyCalls.tenantId, tenantId))
.where(and(...filters))
.orderBy(desc(telephonyCalls.startedAt))
.limit(limit)
})

View File

@@ -60,6 +60,22 @@ const renderDatapointValue = (datapoint) => {
return `${value}${datapoint.unit ? datapoint.unit : ""}`
}
const isTelephonyDatapoint = (datapoint) => {
if (datapoint.telephonyCall) return true
const key = String(datapoint.key || "").toLowerCase()
return [
"phone",
"tel",
"mobile",
"mobiletel",
"phonemobile",
"phonehome",
"fixed_tel",
"mobile_tel",
].some((part) => key.includes(part))
}
</script>
<template>
@@ -84,6 +100,12 @@ const renderDatapointValue = (datapoint) => {
<td>{{datapoint.label}}:</td>
<td>
<component v-if="datapoint.component" :is="datapoint.component" :row="props.item" :in-show="true"></component>
<div v-else-if="isTelephonyDatapoint(datapoint) && getDatapointValue(datapoint)" class="flex flex-wrap items-center gap-2">
<TelephonyCallButton
:number="getDatapointValue(datapoint)"
:label="renderDatapointValue(datapoint)"
/>
</div>
<div v-else>
<span>{{ renderDatapointValue(datapoint) }}</span>
</div>

View File

@@ -0,0 +1,51 @@
<script setup>
const props = defineProps({
number: {
type: [String, Number],
default: ""
},
label: {
type: String,
default: ""
},
size: {
type: String,
default: "xs"
},
variant: {
type: String,
default: "soft"
}
})
const router = useRouter()
const normalizedNumber = computed(() => String(props.number || "").trim())
const displayLabel = computed(() => props.label || normalizedNumber.value)
const canCall = computed(() => normalizedNumber.value.length > 0)
const openSoftphone = async () => {
if (!canCall.value) return
await router.push({
path: "/communication/phone",
query: {
call: normalizedNumber.value,
name: props.label || undefined
}
})
}
</script>
<template>
<UButton
icon="i-heroicons-phone"
color="primary"
:size="size"
:variant="variant"
:disabled="!canCall"
@click="openSoftphone"
>
{{ displayLabel }}
</UButton>
</template>

View File

@@ -90,11 +90,16 @@ export const useTelephonySoftphone = () => {
|| session?.outgoingInviteRequest?.message?.callId
|| null
const loadCallHistory = async () => {
const loadCallHistory = async (filters = {}) => {
callHistoryLoading.value = true
try {
callHistory.value = await $api("/api/telephony/calls?limit=20")
callHistory.value = await $api("/api/telephony/calls", {
params: {
limit: 50,
...filters,
}
})
} catch (error) {
addSipEvent(`Anrufhistorie konnte nicht geladen werden: ${error?.message || "Unbekannter Fehler"}`)
} finally {

View File

@@ -25,6 +25,29 @@ const {
hangupCall,
} = useTelephonySoftphone()
const route = useRoute()
const historyFilters = reactive({
direction: "all",
status: "all",
search: ""
})
const directionOptions = [
{ label: "Alle Richtungen", value: "all" },
{ label: "Eingehend", value: "incoming" },
{ label: "Ausgehend", value: "outgoing" }
]
const statusOptions = [
{ label: "Alle Status", value: "all" },
{ label: "Aktiv", value: "active" },
{ label: "Beendet", value: "completed" },
{ label: "Verpasst", value: "missed" },
{ label: "Fehlgeschlagen", value: "failed" },
{ label: "Abgebrochen", value: "canceled" }
]
const callStatusLabel = (status) => ({
ringing: "Klingelt",
dialing: "Wählt",
@@ -65,7 +88,31 @@ const formatDuration = (seconds) => {
return `${minutes}:${String(rest).padStart(2, "0")} Min.`
}
onMounted(loadTelephony)
const historyFilterParams = () => ({
direction: historyFilters.direction !== "all" ? historyFilters.direction : undefined,
status: historyFilters.status !== "all" ? historyFilters.status : undefined,
search: historyFilters.search?.trim() || undefined
})
const loadFilteredCallHistory = () => loadCallHistory(historyFilterParams())
const setDialTargetFromQuery = () => {
if (route.query.call) {
dialTarget.value = String(route.query.call)
}
}
const callFromHistory = (call) => {
if (!call?.remoteNumber) return
dialTarget.value = call.remoteNumber
}
onMounted(async () => {
setDialTargetFromQuery()
await loadTelephony()
})
watch(() => route.query.call, setDialTargetFromQuery)
</script>
<template>
@@ -94,11 +141,11 @@ onMounted(loadTelephony)
Aktualisieren
</UButton>
<UButton
to="/communication/phone-setup"
to="/settings/telephony"
icon="i-heroicons-cog-6-tooth"
variant="outline"
>
Telefonie-Setup
Einstellungen
</UButton>
<UButton
to="/communication/chat"
@@ -118,7 +165,7 @@ onMounted(loadTelephony)
FEDEO Softphone
</h2>
<p class="mt-1 text-sm text-gray-500">
Registrierung und Anrufe über den lokalen Asterisk.
Registrierung und Anrufe über den angebundenen Asterisk.
</p>
</div>
<UBadge
@@ -180,7 +227,7 @@ onMounted(loadTelephony)
<UInput
v-model="dialTarget"
icon="i-heroicons-hashtag"
placeholder="Ziel, z. B. 600 oder 1002"
placeholder="Zielnummer oder Nebenstelle"
:disabled="!sipRegistered || Boolean(activeSession)"
/>
<UButton
@@ -233,18 +280,43 @@ onMounted(loadTelephony)
Anrufhistorie
</h2>
<p class="mt-1 text-sm text-gray-500">
Letzte Telefonie-Ereignisse dieses Mandanten.
Anrufe dieses Mandanten mit Rückruf und Filter.
</p>
</div>
<div class="flex flex-wrap gap-2">
<UInput
v-model="historyFilters.search"
icon="i-heroicons-magnifying-glass"
placeholder="Suchen"
class="w-44"
@keyup.enter="loadFilteredCallHistory"
/>
<USelectMenu
v-model="historyFilters.direction"
:items="directionOptions"
label-key="label"
value-key="value"
class="w-40"
@update:model-value="loadFilteredCallHistory"
/>
<USelectMenu
v-model="historyFilters.status"
:items="statusOptions"
label-key="label"
value-key="value"
class="w-40"
@update:model-value="loadFilteredCallHistory"
/>
<UButton
icon="i-heroicons-arrow-path"
variant="ghost"
:loading="callHistoryLoading"
@click="loadCallHistory"
@click="loadFilteredCallHistory"
>
Aktualisieren
</UButton>
</div>
</div>
</template>
<div v-if="callHistory.length" class="overflow-x-auto">
@@ -257,6 +329,7 @@ onMounted(loadTelephony)
<th class="px-3 py-2">Nebenstelle</th>
<th class="px-3 py-2">Status</th>
<th class="px-3 py-2 text-right">Dauer</th>
<th class="px-3 py-2 text-right">Aktion</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@@ -293,6 +366,17 @@ onMounted(loadTelephony)
<td class="whitespace-nowrap px-3 py-3 text-right text-gray-600">
{{ formatDuration(call.durationSeconds) }}
</td>
<td class="whitespace-nowrap px-3 py-3 text-right">
<UButton
v-if="call.remoteNumber"
icon="i-heroicons-phone"
size="xs"
variant="soft"
@click="callFromHistory(call)"
>
Rückruf
</UButton>
</td>
</tr>
</tbody>
</table>