Dashboard mit Anrufhistorie und Dev-Schalter ergänzt

This commit is contained in:
Sven committed 2026-09-21 10:52:55 +02:00
1 parent afd6d03a71
commit 373081d33d
11 files changed
+573 -4

No files matched your search

+13
View File
@@ -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);
}
}
+42
View File
@@ -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()
})
})
+38
View File
@@ -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.
* *
+147
View File
@@ -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>
)
}
+46
View File
@@ -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 }
}
+191
View File
@@ -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 {
+23 -4
View File
@@ -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 && (