Dashboard mit Anrufhistorie und Dev-Schalter ergänzt
This commit is contained in:
1 parent
afd6d03a71
commit
373081d33d
11 files changed
+573
-4
No files matched your search
@@ -4,6 +4,19 @@ React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem
|
|||||||
per WebSocket mit der **SwyxTray-App** (`ws://127.0.0.1:17654/ws`), meldet eingehende
|
per WebSocket mit der **SwyxTray-App** (`ws://127.0.0.1:17654/ws`), meldet eingehende
|
||||||
Anrufe und startet ausgehende Anrufe.
|
Anrufe und startet ausgehende Anrufe.
|
||||||
|
|
||||||
|
Standardmäßig öffnet sich ein **Dashboard mit der Historie eingegangener Anrufe**:
|
||||||
|
Rufnummer, Name (falls übermittelt), Datum, Uhrzeit und Leitung, neueste zuerst.
|
||||||
|
Die Historie lässt sich nach Rufnummer oder Name durchsuchen; Kennzahlen zeigen
|
||||||
|
alle eingegangenen Anrufe, die heutigen Anrufe und unterschiedliche Rufnummern.
|
||||||
|
`GET /api/calls` liest dafür die gespeicherten `incoming`-Ereignisse aus MongoDB,
|
||||||
|
auch aus früheren Sitzungen. Das Dashboard aktualisiert sich alle fünf Sekunden.
|
||||||
|
Verbindungs- und Ende-Ereignisse erscheinen nicht als zusätzliche Anrufe.
|
||||||
|
|
||||||
|
Der Schalter **Dev** oben in der Kopfzeile blendet die bisherige Oberfläche mit
|
||||||
|
Anrufen, Adressdaten, Webhook, Tabs und Verbindung ein. Er ist beim Laden der Seite
|
||||||
|
ausgeschaltet. Beim Umschalten bleiben Eingaben und die SwyxTray-Verbindung erhalten;
|
||||||
|
Anruf-Popups und Benachrichtigungen funktionieren auch im Dashboard.
|
||||||
|
|
||||||
```
|
```
|
||||||
SwyxWeb/
|
SwyxWeb/
|
||||||
├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper)
|
├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper)
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ public class ArchiveService {
|
|||||||
submit("Anrufdaten", () -> calls.forEach(mongo::save));
|
submit("Anrufdaten", () -> calls.forEach(mongo::save));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Historie der eingehenden Anrufe, auch aus früheren Browser-Sitzungen. */
|
||||||
|
public List<StoredCall> incomingCalls() {
|
||||||
|
return mongo.find(new Query(Criteria.where("event").is("incoming"))
|
||||||
|
.with(Sort.by(Sort.Direction.DESC, "receivedAt", "id")), StoredCall.class);
|
||||||
|
}
|
||||||
|
|
||||||
private void submit(String what, Runnable write) {
|
private void submit(String what, Runnable write) {
|
||||||
worker.execute(() -> {
|
worker.execute(() -> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.util.List;
|
|||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -37,6 +38,12 @@ public class CallController {
|
|||||||
this.archive = archive;
|
this.archive = archive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eingegangene Anrufe, neueste zuerst; Verbindungs- und Ende-Ereignisse zählen nicht erneut. */
|
||||||
|
@GetMapping
|
||||||
|
public List<StoredCall> list() {
|
||||||
|
return archive.incomingCalls();
|
||||||
|
}
|
||||||
|
|
||||||
/** Ein Anruf-Ereignis, wie es das Frontend schickt – Felder wie im CallEvent. */
|
/** Ein Anruf-Ereignis, wie es das Frontend schickt – Felder wie im CallEvent. */
|
||||||
public record CallEventBody(
|
public record CallEventBody(
|
||||||
Integer line,
|
Integer line,
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ package de.appcreation.swyxweb;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
@@ -15,6 +20,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||||
|
import de.appcreation.swyxweb.storage.StoredCall;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Endpunkt nimmt die Anruf-Ereignisse der Startseite an und bestätigt nur
|
* Der Endpunkt nimmt die Anruf-Ereignisse der Startseite an und bestätigt nur
|
||||||
@@ -31,6 +37,29 @@ class CallControllerTests {
|
|||||||
@MockitoBean
|
@MockitoBean
|
||||||
ArchiveService archive;
|
ArchiveService archive;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsArchivedIncomingCallsWithoutAnActiveTrayConnection() throws Exception {
|
||||||
|
when(archive.incomingCalls()).thenReturn(List.of(new StoredCall(
|
||||||
|
"call-1", Instant.parse("2026-09-20T08:30:00Z"), 1, "incoming", "incoming",
|
||||||
|
"Muster GmbH (+493012345)", "+493012345", "Muster GmbH", "klingelt", "LSRinging")));
|
||||||
|
|
||||||
|
mvc.perform(get("/api/calls"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$[0].id").value("call-1"))
|
||||||
|
.andExpect(jsonPath("$[0].receivedAt").value("2026-09-20T08:30:00Z"))
|
||||||
|
.andExpect(jsonPath("$[0].peerNumber").value("+493012345"))
|
||||||
|
.andExpect(jsonPath("$[0].peerName").value("Muster GmbH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmptyHistoryAsAnEmptyArray() throws Exception {
|
||||||
|
when(archive.incomingCalls()).thenReturn(List.of());
|
||||||
|
mvc.perform(get("/api/calls"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptsEventsAndReturnsCount() throws Exception {
|
void acceptsEventsAndReturnsCount() throws Exception {
|
||||||
mvc.perform(post("/api/calls")
|
mvc.perform(post("/api/calls")
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.appcreation.swyxweb;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
|
import org.springframework.data.mongodb.core.query.Query;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||||
|
import de.appcreation.swyxweb.storage.StoredCall;
|
||||||
|
|
||||||
|
class CallHistoryTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsOnlyIncomingEventsInNewestFirstOrder() {
|
||||||
|
MongoTemplate mongo = mock(MongoTemplate.class);
|
||||||
|
new ArchiveService(mongo).incomingCalls();
|
||||||
|
|
||||||
|
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
|
||||||
|
verify(mongo).find(query.capture(), eq(StoredCall.class));
|
||||||
|
// direction=incoming würde auch connected/ended zählen und Anrufe verdreifachen.
|
||||||
|
assertThat(query.getValue().getQueryObject()).containsOnlyKeys("event")
|
||||||
|
.containsEntry("event", "incoming");
|
||||||
|
assertThat(query.getValue().getSortObject()).containsEntry("receivedAt", -1)
|
||||||
|
.containsEntry("id", -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { callerNumber, fetchIncomingCalls, matchesCall, type StoredCall } from './calls'
|
||||||
|
|
||||||
|
const call: StoredCall = {
|
||||||
|
id: 'call-1',
|
||||||
|
receivedAt: '2026-09-20T08:30:00Z',
|
||||||
|
line: 1,
|
||||||
|
peer: 'Muster GmbH (+49 30 123-45)',
|
||||||
|
peerNumber: '+49 30 123-45',
|
||||||
|
peerName: 'Muster GmbH',
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe('Anrufhistorie', () => {
|
||||||
|
it('liest archivierte Anrufe und unterscheidet Ladefehler von einer leeren Historie', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify([call])))
|
||||||
|
.mockResolvedValueOnce(new Response('[]'))
|
||||||
|
.mockResolvedValueOnce(new Response('', { status: 503 })))
|
||||||
|
|
||||||
|
await expect(fetchIncomingCalls()).resolves.toEqual([call])
|
||||||
|
await expect(fetchIncomingCalls()).resolves.toEqual([])
|
||||||
|
await expect(fetchIncomingCalls()).rejects.toThrow('HTTP 503')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findet Namen unabhängig von Großschreibung und Nummern ohne Formatierung', () => {
|
||||||
|
expect(matchesCall(call, ' MUSTER ')).toBe(true)
|
||||||
|
expect(matchesCall(call, '493012345')).toBe(true)
|
||||||
|
expect(matchesCall(call, '(30) 12345')).toBe(true)
|
||||||
|
expect(matchesCall(call, 'Andere Firma')).toBe(false)
|
||||||
|
expect(matchesCall(call, 'Firma 12345')).toBe(false)
|
||||||
|
expect(matchesCall(call, '')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt bei älteren Ereignissen die Nummer aus peer, aber erfindet keine unbekannte Nummer', () => {
|
||||||
|
expect(callerNumber({ ...call, peerNumber: '' })).toBe('+49 30 123-45')
|
||||||
|
expect(callerNumber({ ...call, peerNumber: null, peer: '5215' })).toBe('5215')
|
||||||
|
expect(callerNumber({ ...call, peerNumber: null, peer: 'unbekannt' })).toBeNull()
|
||||||
|
expect(callerNumber({ ...call, peerNumber: null, peer: 'Muster GmbH' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,43 @@
|
|||||||
import type { CallEvent } from './swyx/protocol'
|
import type { CallEvent } from './swyx/protocol'
|
||||||
|
|
||||||
|
/** Ein gespeicherter eingehender Anruf aus GET /api/calls. */
|
||||||
|
export interface StoredCall {
|
||||||
|
id: string
|
||||||
|
receivedAt: string
|
||||||
|
line: number | null
|
||||||
|
peer: string | null
|
||||||
|
peerNumber: string | null
|
||||||
|
peerName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchIncomingCalls(signal?: AbortSignal): Promise<StoredCall[]> {
|
||||||
|
const response = await fetch('/api/calls', { signal, cache: 'no-store' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Anrufhistorie nicht abrufbar (HTTP ${response.status}).`)
|
||||||
|
}
|
||||||
|
return (await response.json()) as StoredCall[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ältere Snapshots enthalten die Rufnummer teilweise nur in der Anzeigeform. */
|
||||||
|
export function callerNumber(call: StoredCall): string | null {
|
||||||
|
const number = call.peerNumber?.trim()
|
||||||
|
if (number) return number
|
||||||
|
const peer = call.peer?.trim() ?? ''
|
||||||
|
const candidate = peer.match(/\(([^()]+)\)$/)?.[1] ?? peer
|
||||||
|
return /^[+\d][\d\s()/.-]*$/.test(candidate) ? candidate : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesCall(call: StoredCall, query: string): boolean {
|
||||||
|
const wanted = query.trim().toLocaleLowerCase('de')
|
||||||
|
if (!wanted) return true
|
||||||
|
const text = [callerNumber(call), call.peerName, call.peer].filter(Boolean).join(' ')
|
||||||
|
if (text.toLocaleLowerCase('de').includes(wanted)) return true
|
||||||
|
// Rufnummern auch ohne die Leerzeichen/Klammern ihrer Anzeigeform finden.
|
||||||
|
const digits = wanted.replace(/\D/g, '')
|
||||||
|
return /^[+\d\s()/.-]+$/.test(wanted) && digits.length > 0 &&
|
||||||
|
(callerNumber(call)?.replace(/\D/g, '').includes(digits) ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meldet Anruf-Ereignisse an das Backend, das sie in der MongoDB ablegt.
|
* Meldet Anruf-Ereignisse an das Backend, das sie in der MongoDB ablegt.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { callerNumber, matchesCall } from '../calls'
|
||||||
|
import { useCallHistory } from '../hooks/useCallHistory'
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const timeFormat = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
const countFormat = new Intl.NumberFormat('de-DE')
|
||||||
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
|
export default function CallDashboard({ active }: { active: boolean }) {
|
||||||
|
const { calls, error, loading, updatedAt, refresh } = useCallHistory(active)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
|
||||||
|
const filtered = calls?.filter((call) => matchesCall(call, query)) ?? []
|
||||||
|
const today = new Date().toDateString()
|
||||||
|
const todayCount = calls?.filter((call) => new Date(call.receivedAt).toDateString() === today).length
|
||||||
|
const numbers = new Set(
|
||||||
|
calls?.map(callerNumber).filter((number) => number !== null)
|
||||||
|
.map((number) => number.replace(/^\+/, '00').replace(/\D/g, '')),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<div className="dashboard__heading">
|
||||||
|
<div>
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<p className="note">Alle eingegangenen Anrufe im Überblick.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="button" onClick={refresh} disabled={loading}>
|
||||||
|
{loading ? 'Wird geladen …' : 'Aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="dashboard__stats">
|
||||||
|
<div className="dashboard__stat">
|
||||||
|
<dt>Eingegangene Anrufe</dt>
|
||||||
|
<dd>
|
||||||
|
{calls ? countFormat.format(calls.length) : '–'}
|
||||||
|
<span>Gesamte Historie</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard__stat">
|
||||||
|
<dt>Heute</dt>
|
||||||
|
<dd>
|
||||||
|
{todayCount !== undefined ? countFormat.format(todayCount) : '–'}
|
||||||
|
<span>{dateFormat.format(new Date())}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard__stat">
|
||||||
|
<dt>Unterschiedliche Rufnummern</dt>
|
||||||
|
<dd>
|
||||||
|
{calls ? countFormat.format(numbers.size) : '–'}
|
||||||
|
<span>Mit übermittelter Rufnummer</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="note note--error" role="alert">
|
||||||
|
{error} {calls ? 'Der letzte geladene Stand bleibt sichtbar.' : 'Bitte erneut versuchen.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="card dashboard__history" aria-labelledby="call-history-title">
|
||||||
|
<div className="dashboard__history-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="call-history-title">Anrufhistorie</h2>
|
||||||
|
<p className="note">Neueste Anrufe zuerst · automatische Aktualisierung</p>
|
||||||
|
</div>
|
||||||
|
<label className="field dashboard__search">
|
||||||
|
<span className="field__label">Rufnummer oder Name suchen</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rufnummer oder Name"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value)
|
||||||
|
setVisibleCount(PAGE_SIZE)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{calls === null ? (
|
||||||
|
<p className="dashboard__empty" role="status">
|
||||||
|
{loading ? 'Anrufhistorie wird geladen …' : 'Die Anrufhistorie ist momentan nicht verfügbar.'}
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="dashboard__empty" role="status">
|
||||||
|
<strong>{calls.length === 0 ? 'Noch keine eingegangenen Anrufe' : 'Keine passenden Anrufe'}</strong>
|
||||||
|
<p className="note">
|
||||||
|
{calls.length === 0
|
||||||
|
? 'Sobald ein eingehender Anruf erfasst wurde, erscheint er hier.'
|
||||||
|
: 'Versuche eine andere Rufnummer oder einen anderen Namen.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="dashboard__table-scroll" tabIndex={0} role="region" aria-label="Anrufhistorie">
|
||||||
|
<table className="dashboard__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Rufnummer</th>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col">Zeitpunkt</th>
|
||||||
|
<th scope="col">Leitung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.slice(0, visibleCount).map((call) => {
|
||||||
|
const number = callerNumber(call)
|
||||||
|
const date = new Date(call.receivedAt)
|
||||||
|
return (
|
||||||
|
<tr key={call.id}>
|
||||||
|
<td className="dashboard__number">{number ?? 'Unbekannte Rufnummer'}</td>
|
||||||
|
<td>{call.peerName?.trim() || (!number && call.peer?.trim()) || '–'}</td>
|
||||||
|
<td>
|
||||||
|
<time dateTime={call.receivedAt} className="dashboard__time">
|
||||||
|
<span>{dateFormat.format(date)}</span>
|
||||||
|
<span className="note">{timeFormat.format(date)}</span>
|
||||||
|
</time>
|
||||||
|
</td>
|
||||||
|
<td>{call.line ?? '–'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard__footer">
|
||||||
|
<p className="note" role="status">
|
||||||
|
{countFormat.format(Math.min(visibleCount, filtered.length))} von {countFormat.format(filtered.length)} Anrufen
|
||||||
|
</p>
|
||||||
|
{visibleCount < filtered.length && (
|
||||||
|
<button type="button" className="button button--small" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>
|
||||||
|
Weitere Anrufe anzeigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{updatedAt && <p className="note">Zuletzt aktualisiert um {timeFormat.format(updatedAt)} Uhr</p>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { fetchIncomingCalls, type StoredCall } from '../calls'
|
||||||
|
|
||||||
|
/** Liest die dauerhafte Historie; neue Anrufe erscheinen auch ohne Neuladen. */
|
||||||
|
export function useCallHistory(active: boolean) {
|
||||||
|
const [calls, setCalls] = useState<StoredCall[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
const refresh = useCallback(() => setRevision((value) => value + 1), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const controller = new AbortController()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const entries = await fetchIncomingCalls(controller.signal)
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
setCalls(entries)
|
||||||
|
setUpdatedAt(new Date())
|
||||||
|
setError(null)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
// Bei einem vorübergehenden Fehler bleibt der letzte Stand sichtbar.
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false)
|
||||||
|
timer = setTimeout(() => void load(), 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
controller.abort()
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [active, revision])
|
||||||
|
|
||||||
|
return { calls, error, loading, updatedAt, refresh }
|
||||||
|
}
|
||||||
@@ -142,9 +142,200 @@ code {
|
|||||||
.page__header-tools {
|
.page__header-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dev-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-switch:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-switch:focus-visible {
|
||||||
|
outline: 2px solid var(--brand);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-switch__track {
|
||||||
|
width: 30px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-switch__track::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-switch[aria-checked='true'] .dev-switch__track {
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-switch[aria-checked='true'] .dev-switch__track::after {
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eingaben bleiben beim Umschalten erhalten, die Telefonie läuft weiter. */
|
||||||
|
.dev-tools:not([hidden]),
|
||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__heading,
|
||||||
|
.dashboard__history-heading,
|
||||||
|
.dashboard__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__heading h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__stat {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: 3px solid var(--brand);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__stat dt,
|
||||||
|
.dashboard__stat dd span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__stat dt {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__stat dd {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__stat dd span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__history {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__history-heading .note {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard .dashboard__search {
|
||||||
|
flex: 0 1 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px 12px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__empty .note {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 560px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__table th,
|
||||||
|
.dashboard__table td {
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__table th {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__table td {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__table tbody tr:hover {
|
||||||
|
background: var(--brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__time {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dashboard__stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard .dashboard__search {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Marken-Umschalter im Badge-Stil, damit er neben dem Status nicht aus der
|
/* Marken-Umschalter im Badge-Stil, damit er neben dem Status nicht aus der
|
||||||
Reihe fällt. Die Wahl gilt sofort und bleibt über Neustarts erhalten. */
|
Reihe fällt. Die Wahl gilt sofort und bleibt über Neustarts erhalten. */
|
||||||
.brand-switch {
|
.brand-switch {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ContactsPanel from '../components/ContactsPanel'
|
|||||||
import WebhookPanel from '../components/WebhookPanel'
|
import WebhookPanel from '../components/WebhookPanel'
|
||||||
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
|
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
|
||||||
import { ActiveCallList, CallHistory } from '../components/CallList'
|
import { ActiveCallList, CallHistory } from '../components/CallList'
|
||||||
|
import CallDashboard from '../components/CallDashboard'
|
||||||
|
|
||||||
type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
|
type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ function TabPanel({ id, active, children }: { id: TabName; active: boolean; chil
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const [dev, setDev] = useState(false)
|
||||||
const [url, setUrl] = useState(DEFAULT_WS_URL)
|
const [url, setUrl] = useState(DEFAULT_WS_URL)
|
||||||
const [configNote, setConfigNote] = useState<string | null>(null)
|
const [configNote, setConfigNote] = useState<string | null>(null)
|
||||||
const [tab, setTab] = useState<TabName>('calls')
|
const [tab, setTab] = useState<TabName>('calls')
|
||||||
@@ -95,8 +97,8 @@ export default function HomePage() {
|
|||||||
const [seenWebhookId, setSeenWebhookId] = useState(0)
|
const [seenWebhookId, setSeenWebhookId] = useState(0)
|
||||||
const newestWebhookId = webhook.events[0]?.id ?? 0
|
const newestWebhookId = webhook.events[0]?.id ?? 0
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'webhook') setSeenWebhookId(newestWebhookId)
|
if (dev && tab === 'webhook') setSeenWebhookId(newestWebhookId)
|
||||||
}, [tab, newestWebhookId])
|
}, [dev, tab, newestWebhookId])
|
||||||
const unseenWebhook = countUnseen(webhook.events, seenWebhookId)
|
const unseenWebhook = countUnseen(webhook.events, seenWebhookId)
|
||||||
|
|
||||||
const tabsWithBadges = TABS.map((entry) =>
|
const tabsWithBadges = TABS.map((entry) =>
|
||||||
@@ -185,10 +187,26 @@ export default function HomePage() {
|
|||||||
<option value="hansetrans">HANSETRANS</option>
|
<option value="hansetrans">HANSETRANS</option>
|
||||||
</select>
|
</select>
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dev-switch"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={dev}
|
||||||
|
aria-controls="dev-tools"
|
||||||
|
onClick={() => setDev((enabled) => !enabled)}
|
||||||
|
>
|
||||||
|
<span className="dev-switch__track" aria-hidden="true" />
|
||||||
|
Dev
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="page__main">
|
<main className="page__main">
|
||||||
|
<div hidden={dev}>
|
||||||
|
<CallDashboard active={!dev} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dev-tools" className="dev-tools" hidden={!dev}>
|
||||||
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
<TabPanel id="calls" active={tab === 'calls'}>
|
<TabPanel id="calls" active={tab === 'calls'}>
|
||||||
@@ -229,7 +247,7 @@ export default function HomePage() {
|
|||||||
<TabPanel id="contacts" active={tab === 'contacts'}>
|
<TabPanel id="contacts" active={tab === 'contacts'}>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<ContactsPanel
|
<ContactsPanel
|
||||||
active={tab === 'contacts'}
|
active={dev && tab === 'contacts'}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
cache={tray.addressCache}
|
cache={tray.addressCache}
|
||||||
onDial={(number) => void tray.dial(number)}
|
onDial={(number) => void tray.dial(number)}
|
||||||
@@ -252,7 +270,7 @@ export default function HomePage() {
|
|||||||
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<BrowserTabsPanel
|
<BrowserTabsPanel
|
||||||
active={tab === 'browsertabs'}
|
active={dev && tab === 'browsertabs'}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
// Unverpackt weitergereicht: die Rückrufe sind stabil, damit das
|
// Unverpackt weitergereicht: die Rückrufe sind stabil, damit das
|
||||||
@@ -337,6 +355,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{caller && (
|
{caller && (
|
||||||
|
|||||||
Reference in new issue
Block a user