Dashboard mit Anrufhistorie und Dev-Schalter ergänzt
This commit is contained in:
1 parent
afd6d03a71
commit
373081d33d
11 files changed
+713
-144
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
|
||||
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/
|
||||
├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper)
|
||||
|
||||
@@ -141,6 +141,12 @@ public class ArchiveService {
|
||||
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) {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -37,6 +38,12 @@ public class CallController {
|
||||
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. */
|
||||
public record CallEventBody(
|
||||
Integer line,
|
||||
|
||||
@@ -2,10 +2,15 @@ package de.appcreation.swyxweb;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
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.springframework.beans.factory.annotation.Autowired;
|
||||
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 de.appcreation.swyxweb.storage.ArchiveService;
|
||||
import de.appcreation.swyxweb.storage.StoredCall;
|
||||
|
||||
/**
|
||||
* Der Endpunkt nimmt die Anruf-Ereignisse der Startseite an und bestätigt nur
|
||||
@@ -31,6 +37,29 @@ class CallControllerTests {
|
||||
@MockitoBean
|
||||
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
|
||||
void acceptsEventsAndReturnsCount() throws Exception {
|
||||
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'
|
||||
|
||||
/** 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.
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
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
|
||||
Reihe fällt. Die Wahl gilt sofort und bleibt über Neustarts erhalten. */
|
||||
.brand-switch {
|
||||
|
||||
+163
-144
@@ -17,6 +17,7 @@ import ContactsPanel from '../components/ContactsPanel'
|
||||
import WebhookPanel from '../components/WebhookPanel'
|
||||
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
|
||||
import { ActiveCallList, CallHistory } from '../components/CallList'
|
||||
import CallDashboard from '../components/CallDashboard'
|
||||
|
||||
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() {
|
||||
const [dev, setDev] = useState(false)
|
||||
const [url, setUrl] = useState(DEFAULT_WS_URL)
|
||||
const [configNote, setConfigNote] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<TabName>('calls')
|
||||
@@ -95,8 +97,8 @@ export default function HomePage() {
|
||||
const [seenWebhookId, setSeenWebhookId] = useState(0)
|
||||
const newestWebhookId = webhook.events[0]?.id ?? 0
|
||||
useEffect(() => {
|
||||
if (tab === 'webhook') setSeenWebhookId(newestWebhookId)
|
||||
}, [tab, newestWebhookId])
|
||||
if (dev && tab === 'webhook') setSeenWebhookId(newestWebhookId)
|
||||
}, [dev, tab, newestWebhookId])
|
||||
const unseenWebhook = countUnseen(webhook.events, seenWebhookId)
|
||||
|
||||
const tabsWithBadges = TABS.map((entry) =>
|
||||
@@ -185,158 +187,175 @@ export default function HomePage() {
|
||||
<option value="hansetrans">HANSETRANS</option>
|
||||
</select>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main className="page__main">
|
||||
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
||||
<div hidden={dev}>
|
||||
<CallDashboard active={!dev} />
|
||||
</div>
|
||||
|
||||
<TabPanel id="calls" active={tab === 'calls'}>
|
||||
<section className="card">
|
||||
<h2>Anrufen</h2>
|
||||
<DialPanel disabled={!isConnected} busy={busy} onDial={(number) => void tray.dial(number)} />
|
||||
<p className="note">
|
||||
Die Quittung bestätigt nur die Annahme des Wählauftrags – der Verlauf erscheint
|
||||
anschließend unter „Leitungen".
|
||||
</p>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
</section>
|
||||
<div id="dev-tools" className="dev-tools" hidden={!dev}>
|
||||
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
||||
|
||||
<section className="card">
|
||||
<h2>Leitungen</h2>
|
||||
<ActiveCallList
|
||||
calls={calls}
|
||||
busy={busy}
|
||||
onAnswer={(line) => void tray.answer(line)}
|
||||
onHangup={(line) => void tray.hangup(line)}
|
||||
/>
|
||||
{isConnected && (appState.statusText || appState.user) && (
|
||||
<p className={`note${appState.connected === false ? ' note--error' : ''}`}>
|
||||
SwyxIt!: {appState.statusText ?? appState.overall ?? 'unbekannt'}
|
||||
{appState.user && ` · ${appState.user}`}
|
||||
{appState.connected === false && ' · keine Verbindung zu SwyxIt!'}
|
||||
{appState.serverUp === false && ' · SwyxServer nicht erreichbar'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Verlauf</h2>
|
||||
<CallHistory events={history} />
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="contacts" active={tab === 'contacts'}>
|
||||
<section className="card">
|
||||
<ContactsPanel
|
||||
active={tab === 'contacts'}
|
||||
disabled={!isConnected}
|
||||
cache={tray.addressCache}
|
||||
onDial={(number) => void tray.dial(number)}
|
||||
/>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="webhook" active={tab === 'webhook'}>
|
||||
<section className="card">
|
||||
<WebhookPanel
|
||||
events={webhook.events}
|
||||
status={webhook.status}
|
||||
error={webhook.error}
|
||||
onClear={() => void webhook.clear()}
|
||||
/>
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
||||
<section className="card">
|
||||
<BrowserTabsPanel
|
||||
active={tab === 'browsertabs'}
|
||||
disabled={!isConnected}
|
||||
busy={busy}
|
||||
// Unverpackt weitergereicht: die Rückrufe sind stabil, damit das
|
||||
// Panel nicht bei jedem Rendern neu lädt.
|
||||
onList={tray.listTabs}
|
||||
onOpen={tray.openTab}
|
||||
onClose={tray.closeTab}
|
||||
/>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="connection" active={tab === 'connection'}>
|
||||
<section className="card">
|
||||
<h2>Verbindung</h2>
|
||||
|
||||
<form className="row" onSubmit={handleConnect}>
|
||||
<label className="field">
|
||||
<span className="field__label">SwyxTray-Adresse</span>
|
||||
<input
|
||||
className="field__input"
|
||||
type="text"
|
||||
value={url}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="ws://127.0.0.1:17654/ws"
|
||||
onChange={(e) => {
|
||||
urlTouched.current = true
|
||||
setUrl(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="row__actions">
|
||||
<button type="submit" className="button button--primary" disabled={!url.trim()}>
|
||||
{isConnected ? 'Neu verbinden' : 'Verbinden'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={handleDisconnect}
|
||||
disabled={!isBusyConnection}
|
||||
>
|
||||
Trennen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{configNote && <p className="note">{configNote}</p>}
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
{permission !== 'granted' && (
|
||||
<TabPanel id="calls" active={tab === 'calls'}>
|
||||
<section className="card">
|
||||
<h2>Anrufen</h2>
|
||||
<DialPanel disabled={!isConnected} busy={busy} onDial={(number) => void tray.dial(number)} />
|
||||
<p className="note">
|
||||
Für Hinweise bei eingehenden Anrufen auch außerhalb des Tabs:{' '}
|
||||
<button type="button" className="button button--ghost" onClick={requestPermission}>
|
||||
Benachrichtigungen erlauben
|
||||
</button>
|
||||
Die Quittung bestätigt nur die Annahme des Wählauftrags – der Verlauf erscheint
|
||||
anschließend unter „Leitungen".
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Diagnose</h2>
|
||||
<MessageLog entries={log} />
|
||||
<div className="row">
|
||||
<div className="row__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => void tray.refresh()}
|
||||
disabled={!isConnected || busy}
|
||||
>
|
||||
Status abfragen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={tray.clearLog}
|
||||
disabled={log.length === 0}
|
||||
>
|
||||
Log leeren
|
||||
</button>
|
||||
<section className="card">
|
||||
<h2>Leitungen</h2>
|
||||
<ActiveCallList
|
||||
calls={calls}
|
||||
busy={busy}
|
||||
onAnswer={(line) => void tray.answer(line)}
|
||||
onHangup={(line) => void tray.hangup(line)}
|
||||
/>
|
||||
{isConnected && (appState.statusText || appState.user) && (
|
||||
<p className={`note${appState.connected === false ? ' note--error' : ''}`}>
|
||||
SwyxIt!: {appState.statusText ?? appState.overall ?? 'unbekannt'}
|
||||
{appState.user && ` · ${appState.user}`}
|
||||
{appState.connected === false && ' · keine Verbindung zu SwyxIt!'}
|
||||
{appState.serverUp === false && ' · SwyxServer nicht erreichbar'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Verlauf</h2>
|
||||
<CallHistory events={history} />
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="contacts" active={tab === 'contacts'}>
|
||||
<section className="card">
|
||||
<ContactsPanel
|
||||
active={dev && tab === 'contacts'}
|
||||
disabled={!isConnected}
|
||||
cache={tray.addressCache}
|
||||
onDial={(number) => void tray.dial(number)}
|
||||
/>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="webhook" active={tab === 'webhook'}>
|
||||
<section className="card">
|
||||
<WebhookPanel
|
||||
events={webhook.events}
|
||||
status={webhook.status}
|
||||
error={webhook.error}
|
||||
onClear={() => void webhook.clear()}
|
||||
/>
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
||||
<section className="card">
|
||||
<BrowserTabsPanel
|
||||
active={dev && tab === 'browsertabs'}
|
||||
disabled={!isConnected}
|
||||
busy={busy}
|
||||
// Unverpackt weitergereicht: die Rückrufe sind stabil, damit das
|
||||
// Panel nicht bei jedem Rendern neu lädt.
|
||||
onList={tray.listTabs}
|
||||
onOpen={tray.openTab}
|
||||
onClose={tray.closeTab}
|
||||
/>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="connection" active={tab === 'connection'}>
|
||||
<section className="card">
|
||||
<h2>Verbindung</h2>
|
||||
|
||||
<form className="row" onSubmit={handleConnect}>
|
||||
<label className="field">
|
||||
<span className="field__label">SwyxTray-Adresse</span>
|
||||
<input
|
||||
className="field__input"
|
||||
type="text"
|
||||
value={url}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="ws://127.0.0.1:17654/ws"
|
||||
onChange={(e) => {
|
||||
urlTouched.current = true
|
||||
setUrl(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="row__actions">
|
||||
<button type="submit" className="button button--primary" disabled={!url.trim()}>
|
||||
{isConnected ? 'Neu verbinden' : 'Verbinden'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={handleDisconnect}
|
||||
disabled={!isBusyConnection}
|
||||
>
|
||||
Trennen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{configNote && <p className="note">{configNote}</p>}
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
{permission !== 'granted' && (
|
||||
<p className="note">
|
||||
Für Hinweise bei eingehenden Anrufen auch außerhalb des Tabs:{' '}
|
||||
<button type="button" className="button button--ghost" onClick={requestPermission}>
|
||||
Benachrichtigungen erlauben
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Diagnose</h2>
|
||||
<MessageLog entries={log} />
|
||||
<div className="row">
|
||||
<div className="row__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => void tray.refresh()}
|
||||
disabled={!isConnected || busy}
|
||||
>
|
||||
Status abfragen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={tray.clearLog}
|
||||
disabled={log.length === 0}
|
||||
>
|
||||
Log leeren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</TabPanel>
|
||||
</section>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{caller && (
|
||||
|
||||
Reference in new issue
Block a user