Anruf-Popup mit Job-Knöpfen und Annehmen; Anrufkarte entfernt

Adressen tragen die Kennungen ihrer Jobs (job_ids) – das Anruf-Popup zeigt
je Kennung einen Knopf, der den Job über den neuen Endpunkt
GET /api/jobs/{id} aus der Sammlung holt und seine Sprungadresse (url) in
einem neuen Tab öffnet; Fehler (unbekannte Kennung, Ablage nicht
erreichbar) erscheinen im Popup. Der Knopf "Auftrag öffnen" (customer.url)
ist entfallen.

Das Popup öffnet jetzt bei jedem eingehenden Anruf – mit Kundendaten, wenn
die Rufnummer einem Kunden gehört, sonst nur mit der Rufnummer – und trägt
links unten einen grünen Knopf "Anruf annehmen", solange es klingelt. Die
Anrufkarte über den Tabs (IncomingCallCard) ist damit überflüssig und
samt Styles ausgebaut; ein Ablehnen-Knopf fehlt dem Popup noch.

Die Debug-Starts beenden vorab einen Altlauf des Backends: eine Task macht
Port 8080 frei (nur der Lauscher – lsof ohne -sTCP:LISTEN träfe auch den
Vite-Proxy) und hängt als preLaunchTask an beiden Backend-Konfigurationen;
tasks.json wandert dafür mit ins Repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 11:07:14 +02:00
co-authored by Claude Fable 5
parent 3a86bc1eee
commit ab5e402ee9
12 changed files with 287 additions and 155 deletions
+1
View File
@@ -16,6 +16,7 @@ frontend/.env.local
# sonst greift die Ausnahme für die Datei darin nicht.
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
*.iml
.DS_Store
*.log
+6 -2
View File
@@ -9,7 +9,10 @@
"mainClass": "de.appcreation.swyxweb.BackendApplication",
"projectName": "backend",
"cwd": "${workspaceFolder}/backend",
"console": "internalConsole"
"console": "internalConsole",
// Ein noch laufendes Backend würde Port 8080 belegen und weiter den
// alten Stand ausliefern vor dem Start wird es deshalb beendet.
"preLaunchTask": "Backend-Port 8080 freimachen"
},
{
// Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
@@ -28,7 +31,8 @@
"--spring.profiles.active=mock",
"--app.websocket.host=localhost",
"--app.websocket.port=8080"
]
],
"preLaunchTask": "Backend-Port 8080 freimachen"
},
{
"type": "node-terminal",
+24
View File
@@ -0,0 +1,24 @@
{
"version": "2.0.0",
"tasks": [
{
// Beendet ein noch laufendes Backend (den Lauscher auf Port 8080), damit
// der Debug-Start immer den frischen Stand startet statt an der
// Portbelegung zu scheitern egal ob der Altlauf aus VS Code,
// "mvnw spring-boot:run" oder einem Terminal stammt. Nur -sTCP:LISTEN:
// ohne die Einschränkung träfe lsof auch Prozesse mit offener Verbindung
// zum Backend, etwa den Vite-Dev-Server (Proxy). Wartet kurz, bis der
// Port wirklich frei ist; "exit 0" auch ohne Treffer, sonst bräche der
// Launch ab.
"label": "Backend-Port 8080 freimachen",
"type": "shell",
"command": "pids=$(lsof -ti tcp:8080 -sTCP:LISTEN); if [ -n \"$pids\" ]; then echo \"Beende laufendes Backend (PID $pids) …\"; kill $pids; for i in $(seq 1 25); do lsof -ti tcp:8080 -sTCP:LISTEN >/dev/null || break; sleep 0.2; done; fi; exit 0",
"presentation": {
"reveal": "silent",
"panel": "shared",
"close": true
},
"problemMatcher": []
}
]
}
@@ -105,6 +105,16 @@ public class ArchiveService {
submit("Jobdaten", () -> mongo.save(job));
}
/**
* Der Job zur Kennung des Fremdsystems gelesen direkt aus der Sammlung
* {@code jobs}, wie {@link #addresses} ohne Zwischenstand im Backend.
*
* @return der Job, oder {@code null} wenn die Kennung nicht abgelegt ist
*/
public JobEntry job(long id) {
return mongo.findById(id, JobEntry.class);
}
/**
* Löscht alle Jobs, deren {@code ordertime} vor der Grenze liegt (Vergleich
* als Text, siehe {@link JobCleanupService}). Läuft anders als die
@@ -0,0 +1,39 @@
package de.appcreation.swyxweb.web;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.storage.JobEntry;
/**
* Jobs aus der MongoDB herausgeben. Herein kommen sie über den Webhook (siehe
* WebhookController); hier holt sich der Browser einen einzelnen Job zu einer
* Kennung aus {@code job_ids} eines Kunden etwa um beim Anruf-Popup die
* Sprungadresse ({@code url}) des Jobs zu öffnen.
*/
@RestController
@RequestMapping("/api/jobs")
public class JobController {
private final ArchiveService archive;
public JobController(ArchiveService archive) {
this.archive = archive;
}
/** Der Job zur Kennung des Fremdsystems; 404, wenn er nicht abgelegt ist. */
@GetMapping("/{id}")
public JobEntry get(@PathVariable long id) {
JobEntry job = archive.job(id);
if (job == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Kein Job mit der Kennung " + id + " in der Ablage.");
}
return job;
}
}
@@ -0,0 +1,53 @@
package de.appcreation.swyxweb;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
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.JobEntry;
/**
* Der Endpunkt liefert einen einzelnen Job zur Kennung des Fremdsystems so
* kommt das Anruf-Popup von einer Kennung aus {@code job_ids} an die
* Sprungadresse des Jobs. Der {@link ArchiveService} ist ersetzt der Test
* läuft ohne MongoDB.
*/
@SpringBootTest
@AutoConfigureMockMvc
class JobControllerTests {
@Autowired
MockMvc mvc;
@MockitoBean
ArchiveService archive;
@Test
void returnsStoredJob() throws Exception {
when(archive.job(21891263L)).thenReturn(new JobEntry(21891263L, null,
"https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
1, "2026-08-13T13:35:27", null, null, null, null, null, null, null, null, null, null));
mvc.perform(get("/api/jobs/21891263"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(21891263))
.andExpect(jsonPath("$.url").value("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263"));
}
@Test
void unknownIdIsNotFound() throws Exception {
when(archive.job(4711L)).thenReturn(null);
mvc.perform(get("/api/jobs/4711"))
.andExpect(status().isNotFound());
}
}
+69 -20
View File
@@ -1,24 +1,34 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { StoredAddress } from '../addresses'
import { fetchJob } from '../jobs'
interface Props {
/** Der Kunde aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */
customer: StoredAddress
/**
* Nimmt den Anruf an; solange er klingelt gesetzt, danach `undefined`
* der Knopf „Anruf annehmen" verschwindet dann von selbst.
*/
onAnswer?: () => void
onClose: () => void
}
/**
* Popup zu einem eingehenden Anruf, dessen Rufnummer zu einem Kunden in der
* Adress-Ablage gehört: zeigt die Kundendaten und wenn das Fremdsystem eine
* Sprungadresse mitgeliefert hat einen Knopf, der den Auftrag in einem neuen
* Tab öffnet. Gleiches Overlay wie der Wartedialog (siehe LoadingDialog zur
* Begründung gegen `<dialog>`).
* Adress-Ablage gehört: zeigt die Kundendaten, dazu je Job-Kennung des Kunden
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
* Wartedialog (siehe LoadingDialog zur Begründung gegen `<dialog>`).
*/
export default function CustomerCallPopup({ customer, onClose }: Props) {
export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
// Meldung, wenn ein Job nicht zu öffnen war (nicht abgelegt, ohne
// Sprungadresse, Ablage nicht erreichbar) das Popup bleibt dann offen.
const [jobNote, setJobNote] = useState<string | null>(null)
// Kennung des Jobs, der gerade geholt wird; sperrt derweil alle Job-Knöpfe.
const [busyJobId, setBusyJobId] = useState<number | null>(null)
// Fokus in den Dialog holen, damit Escape sofort greift; der Auftrags-Knopf
// ist ein Link und behält die Bedienung des Browsers (neuer Tab).
// Fokus in den Dialog holen, damit Escape sofort greift.
useEffect(() => {
closeRef.current?.focus()
}, [])
@@ -31,6 +41,27 @@ export default function CustomerCallPopup({ customer, onClose }: Props) {
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
const jobIds = customer.jobIds ?? []
async function openJob(jobId: number) {
setBusyJobId(jobId)
setJobNote(null)
try {
const job = await fetchJob(jobId)
if (!job.url) {
setJobNote(`Zu Job ${jobId} ist keine Sprungadresse abgelegt.`)
return
}
window.open(job.url, '_blank', 'noopener,noreferrer')
// Der neue Tab ist offen; das Popup hat damit seinen Zweck erfüllt.
onClose()
} catch (e) {
setJobNote(e instanceof Error ? e.message : String(e))
} finally {
setBusyJobId(null)
}
}
return (
<div className="overlay">
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="caller-popup-title">
@@ -42,19 +73,37 @@ export default function CustomerCallPopup({ customer, onClose }: Props) {
{customer.description && <p className="note">{customer.description}</p>}
{customer.cscId != null && <p className="note">Kundennummer {customer.cscId}</p>}
<div className="dialog__actions">
{customer.url && (
<a
className="button button--primary"
href={customer.url}
target="_blank"
rel="noopener noreferrer"
// Der Browser öffnet den neuen Tab; das Popup hat damit seinen
// Zweck erfüllt und schließt sich.
onClick={onClose}
{jobIds.length > 0 && (
<>
<p className="dialog__status">Zugeordnete Jobs</p>
<div className="dialog__jobs">
{jobIds.map((jobId) => (
<button
key={jobId}
type="button"
className="button"
disabled={busyJobId != null}
onClick={() => void openJob(jobId)}
>
Auftrag öffnen
</a>
{busyJobId === jobId ? `Job ${jobId}` : `Job ${jobId}`}
</button>
))}
</div>
</>
)}
{jobNote && <p className="note note--error">{jobNote}</p>}
<div className="dialog__actions">
{onAnswer && (
// Das Popup bleibt nach dem Annehmen offen: Die Job-Knöpfe werden
// ja gerade während des Gesprächs gebraucht.
<button
type="button"
className="button button--accept dialog__actions-start"
onClick={onAnswer}
>
Anruf annehmen
</button>
)}
<button ref={closeRef} type="button" className="button" onClick={onClose}>
Schließen
@@ -1,48 +0,0 @@
import { type CallEvent } from '../swyx/protocol'
interface Props {
call: CallEvent
busy: boolean
onAnswer: (line: number) => void
onHangup: (line: number) => void
}
/** Meldung für einen eingehenden, noch klingelnden Anruf. */
export default function IncomingCallCard({ call, busy, onAnswer, onHangup }: Props) {
// Name und Nummer getrennt anzeigen, sonst die fertige Anzeigeform der App.
const title = call.peerName ?? call.peerNumber ?? call.peer ?? `Leitung ${call.line}`
const subtitle = call.peerName ? call.peerNumber : undefined
return (
<section className="card card--ringing" role="alert">
<div className="ringing">
<span className="ringing__icon" aria-hidden="true">
</span>
<div className="ringing__text">
<p className="ringing__label">Eingehender Anruf · Leitung {call.line}</p>
<p className="ringing__number">{title}</p>
{subtitle && <p className="ringing__meta">{subtitle}</p>}
</div>
<div className="ringing__actions">
<button
type="button"
className="button button--accept"
disabled={busy}
onClick={() => onAnswer(call.line)}
>
Annehmen
</button>
<button
type="button"
className="button button--reject"
disabled={busy}
onClick={() => onHangup(call.line)}
>
Ablehnen
</button>
</div>
</div>
</section>
)
}
+14 -65
View File
@@ -427,71 +427,6 @@ a.button {
}
}
/* --- Eingehender Anruf --- */
.card--ringing {
border-color: var(--ok);
box-shadow: 0 0 0 1px var(--ok);
}
.ringing {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.ringing__icon {
font-size: 30px;
animation: shake 1s ease-in-out infinite;
}
.ringing__text {
flex: 1 1 200px;
min-width: 0;
}
.ringing__label {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.ringing__number {
margin: 2px 0 0;
font-size: 22px;
font-weight: 600;
word-break: break-all;
}
.ringing__meta {
margin: 2px 0 0;
color: var(--text-muted);
font-size: 13px;
}
.ringing__actions {
display: flex;
gap: 8px;
}
@keyframes shake {
25% {
transform: rotate(-14deg);
}
75% {
transform: rotate(14deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ringing__icon {
animation: none;
}
}
.button--accept {
background: var(--ok);
border-color: var(--ok);
@@ -736,9 +671,23 @@ a.button {
.dialog__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
/* Schiebt einen Knopf an den linken Rand der Aktionszeile („Anruf annehmen"). */
.dialog__actions-start {
margin-right: auto;
}
/* Die Job-Knöpfe des Anruf-Popups: einer je Kennung, umbrechend. */
.dialog__jobs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.progress {
height: 8px;
border-radius: 999px;
+35 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Job } from './jobs'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Job, fetchJob } from './jobs'
/**
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java)
@@ -119,3 +119,36 @@ describe('Job.toJson', () => {
expect(Job.fromJson(json)).toEqual(job)
})
})
/**
* Über diesen Weg öffnet das Anruf-Popup einen Job aus `job_ids` des Kunden:
* Kennung → Job aus der Ablage → Sprungadresse (`url`).
*/
describe('fetchJob', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('holt den Job zur Kennung vom Backend', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(SAMPLE, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const job = await fetchJob(21891263)
expect(fetchMock).toHaveBeenCalledWith('/api/jobs/21891263')
expect(job.id).toBe(21891263)
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
})
it('meldet eine unbekannte Kennung verständlich', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 404 })))
await expect(fetchJob(4711)).rejects.toThrowError(/Kein Job mit der Kennung 4711/)
})
it('meldet eine nicht erreichbare Ablage verständlich', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 502 })))
await expect(fetchJob(4711)).rejects.toThrowError(/HTTP 502/)
})
})
+18
View File
@@ -17,6 +17,24 @@
* `phone` beide Schreibweisen werden angenommen.
*/
/**
* Holt einen einzelnen Job über das Backend aus der Sammlung `jobs` etwa zu
* einer Kennung aus `job_ids` eines Kunden, um dessen Sprungadresse (`url`) zu
* öffnen.
*
* @throws wenn die Kennung nicht abgelegt oder die Ablage nicht erreichbar ist
*/
export async function fetchJob(id: number): Promise<Job> {
const response = await fetch(`/api/jobs/${id}`)
if (response.status === 404) {
throw new Error(`Kein Job mit der Kennung ${id} in der Ablage.`)
}
if (!response.ok) {
throw new Error(`Job-Ablage nicht abrufbar (/api/jobs/${id} antwortete mit HTTP ${response.status}).`)
}
return Job.fromJson(await response.json())
}
/** Zeichenkette der Nutzlast; leer, `null` oder fehlend wird `undefined`. */
function text(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
+19 -19
View File
@@ -11,7 +11,6 @@ import CustomerCallPopup from '../components/CustomerCallPopup'
import StadtboteSignet from '../components/StadtboteSignet'
import HansetransSignet from '../components/HansetransSignet'
import MessageLog from '../components/MessageLog'
import IncomingCallCard from '../components/IncomingCallCard'
import DialPanel from '../components/DialPanel'
import BrowserTabsPanel from '../components/BrowserTabsPanel'
import ContactsPanel from '../components/ContactsPanel'
@@ -53,9 +52,10 @@ export default function HomePage() {
const { permission, requestPermission } = useCallNotifications(ringingCall)
// Meldet SwyxIt! einen eingehenden Anruf, wird die Rufnummer in der
// Adress-Ablage gesucht; gehört sie einem Kunden, geht das Popup mit dessen
// Daten auf. Je Anruf nur eine Abfrage der Snapshot kommt mehrfach.
// Meldet SwyxIt! einen eingehenden Anruf, geht das Popup auf mit den
// Kundendaten, wenn die Rufnummer in der Adress-Ablage einem Kunden gehört,
// sonst nur mit der Rufnummer. Je Anruf nur eine Abfrage der Snapshot
// kommt mehrfach.
const [caller, setCaller] = useState<StoredAddress | null>(null)
const lookedUpRef = useRef<string | null>(null)
useEffect(() => {
@@ -72,11 +72,12 @@ export default function HomePage() {
let stale = false
findCustomerByNumber(number)
.then((match) => {
if (!stale && match) setCaller(match)
if (!stale) setCaller(match ?? { number })
})
.catch(() => {
// Ablage nicht erreichbar dann eben kein Popup; der Anruf selbst
// wird ohnehin über die Anrufkarte gemeldet.
// Ablage nicht erreichbar das Popup zeigt dann eben nur die
// Rufnummer; annehmen lässt sich der Anruf trotzdem.
if (!stale) setCaller({ number })
})
return () => {
stale = true
@@ -188,17 +189,6 @@ export default function HomePage() {
</header>
<main className="page__main">
{/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon
abhängen, welcher Bereich gerade offen ist. */}
{ringingCall && (
<IncomingCallCard
call={ringingCall}
busy={busy}
onAnswer={(line) => void tray.answer(line)}
onHangup={(line) => void tray.hangup(line)}
/>
)}
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
<TabPanel id="calls" active={tab === 'calls'}>
@@ -349,7 +339,17 @@ export default function HomePage() {
</TabPanel>
</main>
{caller && <CustomerCallPopup customer={caller} onClose={() => setCaller(null)} />}
{caller && (
<CustomerCallPopup
customer={caller}
// Nur solange es klingelt: Danach fällt der Annehmen-Knopf im Popup
// von selbst weg, die Kundendaten und Job-Knöpfe bleiben stehen.
onAnswer={
ringingCall && !busy ? () => void tray.answer(ringingCall.line) : undefined
}
onClose={() => setCaller(null)}
/>
)}
</div>
)
}