diff --git a/.gitignore b/.gitignore index aff3bf3..247a9a9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.vscode/launch.json b/.vscode/launch.json index 72c9d63..bf3ceec 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d8a04ff --- /dev/null +++ b/.vscode/tasks.json @@ -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": [] + } + ] +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java index b4a2c4d..0979986 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java @@ -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 diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/JobController.java b/backend/src/main/java/de/appcreation/swyxweb/web/JobController.java new file mode 100644 index 0000000..110268f --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/web/JobController.java @@ -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; + } +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobControllerTests.java new file mode 100644 index 0000000..ff91f2a --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/JobControllerTests.java @@ -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()); + } +} diff --git a/frontend/src/components/CustomerCallPopup.tsx b/frontend/src/components/CustomerCallPopup.tsx index 06cf59f..38749fe 100644 --- a/frontend/src/components/CustomerCallPopup.tsx +++ b/frontend/src/components/CustomerCallPopup.tsx @@ -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 ``). + * 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 ``). */ -export default function CustomerCallPopup({ customer, onClose }: Props) { +export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) { const closeRef = useRef(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(null) + // Kennung des Jobs, der gerade geholt wird; sperrt derweil alle Job-Knöpfe. + const [busyJobId, setBusyJobId] = useState(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 (
@@ -42,19 +73,37 @@ export default function CustomerCallPopup({ customer, onClose }: Props) { {customer.description &&

{customer.description}

} {customer.cscId != null &&

Kundennummer {customer.cscId}

} + {jobIds.length > 0 && ( + <> +

Zugeordnete Jobs

+
+ {jobIds.map((jobId) => ( + + ))} +
+ + )} + {jobNote &&

{jobNote}

} +
- {customer.url && ( - - Auftrag öffnen - + Anruf annehmen + )} - -
-
- - ) -} diff --git a/frontend/src/index.css b/frontend/src/index.css index ccb4b1d..aa0418d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/jobs.test.ts b/frontend/src/jobs.test.ts index b56e3c2..22d286d 100644 --- a/frontend/src/jobs.test.ts +++ b/frontend/src/jobs.test.ts @@ -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/) + }) +}) diff --git a/frontend/src/jobs.ts b/frontend/src/jobs.ts index 5f096d8..b978324 100644 --- a/frontend/src/jobs.ts +++ b/frontend/src/jobs.ts @@ -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 { + 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 diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 47a8cec..75c3553 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -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(null) const lookedUpRef = useRef(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() {
- {/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon - abhängen, welcher Bereich gerade offen ist. */} - {ringingCall && ( - void tray.answer(line)} - onHangup={(line) => void tray.hangup(line)} - /> - )} - @@ -349,7 +339,17 @@ export default function HomePage() {
- {caller && setCaller(null)} />} + {caller && ( + void tray.answer(ringingCall.line) : undefined + } + onClose={() => setCaller(null)} + /> + )}
) }