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:
@@ -16,6 +16,7 @@ frontend/.env.local
|
|||||||
# sonst greift die Ausnahme für die Datei darin nicht.
|
# sonst greift die Ausnahme für die Datei darin nicht.
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
|
!.vscode/tasks.json
|
||||||
*.iml
|
*.iml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
Vendored
+6
-2
@@ -9,7 +9,10 @@
|
|||||||
"mainClass": "de.appcreation.swyxweb.BackendApplication",
|
"mainClass": "de.appcreation.swyxweb.BackendApplication",
|
||||||
"projectName": "backend",
|
"projectName": "backend",
|
||||||
"cwd": "${workspaceFolder}/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"
|
// Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
|
||||||
@@ -28,7 +31,8 @@
|
|||||||
"--spring.profiles.active=mock",
|
"--spring.profiles.active=mock",
|
||||||
"--app.websocket.host=localhost",
|
"--app.websocket.host=localhost",
|
||||||
"--app.websocket.port=8080"
|
"--app.websocket.port=8080"
|
||||||
]
|
],
|
||||||
|
"preLaunchTask": "Backend-Port 8080 freimachen"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
|
|||||||
Vendored
+24
@@ -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));
|
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
|
* Löscht alle Jobs, deren {@code ordertime} vor der Grenze liegt (Vergleich
|
||||||
* als Text, siehe {@link JobCleanupService}). Läuft anders als die
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,34 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { StoredAddress } from '../addresses'
|
import type { StoredAddress } from '../addresses'
|
||||||
|
import { fetchJob } from '../jobs'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Der Kunde aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */
|
/** Der Kunde aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */
|
||||||
customer: StoredAddress
|
customer: StoredAddress
|
||||||
|
/**
|
||||||
|
* Nimmt den Anruf an; solange er klingelt gesetzt, danach `undefined` –
|
||||||
|
* der Knopf „Anruf annehmen" verschwindet dann von selbst.
|
||||||
|
*/
|
||||||
|
onAnswer?: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Popup zu einem eingehenden Anruf, dessen Rufnummer zu einem Kunden in der
|
* Popup zu einem eingehenden Anruf, dessen Rufnummer zu einem Kunden in der
|
||||||
* Adress-Ablage gehört: zeigt die Kundendaten und – wenn das Fremdsystem eine
|
* Adress-Ablage gehört: zeigt die Kundendaten, dazu je Job-Kennung des Kunden
|
||||||
* Sprungadresse mitgeliefert hat – einen Knopf, der den Auftrag in einem neuen
|
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
|
||||||
* Tab öffnet. Gleiches Overlay wie der Wartedialog (siehe LoadingDialog zur
|
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
|
||||||
* Begründung gegen `<dialog>`).
|
* 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)
|
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
|
// Fokus in den Dialog holen, damit Escape sofort greift.
|
||||||
// ist ein Link und behält die Bedienung des Browsers (neuer Tab).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeRef.current?.focus()
|
closeRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -31,6 +41,27 @@ export default function CustomerCallPopup({ customer, onClose }: Props) {
|
|||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
}, [onClose])
|
}, [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 (
|
return (
|
||||||
<div className="overlay">
|
<div className="overlay">
|
||||||
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="caller-popup-title">
|
<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.description && <p className="note">{customer.description}</p>}
|
||||||
{customer.cscId != null && <p className="note">Kundennummer {customer.cscId}</p>}
|
{customer.cscId != null && <p className="note">Kundennummer {customer.cscId}</p>}
|
||||||
|
|
||||||
|
{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)}
|
||||||
|
>
|
||||||
|
{busyJobId === jobId ? `Job ${jobId} …` : `Job ${jobId}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{jobNote && <p className="note note--error">{jobNote}</p>}
|
||||||
|
|
||||||
<div className="dialog__actions">
|
<div className="dialog__actions">
|
||||||
{customer.url && (
|
{onAnswer && (
|
||||||
<a
|
// Das Popup bleibt nach dem Annehmen offen: Die Job-Knöpfe werden
|
||||||
className="button button--primary"
|
// ja gerade während des Gesprächs gebraucht.
|
||||||
href={customer.url}
|
<button
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
className="button button--accept dialog__actions-start"
|
||||||
// Der Browser öffnet den neuen Tab; das Popup hat damit seinen
|
onClick={onAnswer}
|
||||||
// Zweck erfüllt und schließt sich.
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
Auftrag öffnen
|
Anruf annehmen
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button ref={closeRef} type="button" className="button" onClick={onClose}>
|
<button ref={closeRef} type="button" className="button" onClick={onClose}>
|
||||||
Schließen
|
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
@@ -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 {
|
.button--accept {
|
||||||
background: var(--ok);
|
background: var(--ok);
|
||||||
border-color: var(--ok);
|
border-color: var(--ok);
|
||||||
@@ -736,9 +671,23 @@ a.button {
|
|||||||
.dialog__actions {
|
.dialog__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 20px;
|
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 {
|
.progress {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Job } from './jobs'
|
import { Job, fetchJob } from './jobs'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java) –
|
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java) –
|
||||||
@@ -119,3 +119,36 @@ describe('Job.toJson', () => {
|
|||||||
expect(Job.fromJson(json)).toEqual(job)
|
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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -17,6 +17,24 @@
|
|||||||
* `phone` – beide Schreibweisen werden angenommen.
|
* `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`. */
|
/** Zeichenkette der Nutzlast; leer, `null` oder fehlend wird `undefined`. */
|
||||||
function text(value: unknown): string | undefined {
|
function text(value: unknown): string | undefined {
|
||||||
return typeof value === 'string' && value !== '' ? value : undefined
|
return typeof value === 'string' && value !== '' ? value : undefined
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import CustomerCallPopup from '../components/CustomerCallPopup'
|
|||||||
import StadtboteSignet from '../components/StadtboteSignet'
|
import StadtboteSignet from '../components/StadtboteSignet'
|
||||||
import HansetransSignet from '../components/HansetransSignet'
|
import HansetransSignet from '../components/HansetransSignet'
|
||||||
import MessageLog from '../components/MessageLog'
|
import MessageLog from '../components/MessageLog'
|
||||||
import IncomingCallCard from '../components/IncomingCallCard'
|
|
||||||
import DialPanel from '../components/DialPanel'
|
import DialPanel from '../components/DialPanel'
|
||||||
import BrowserTabsPanel from '../components/BrowserTabsPanel'
|
import BrowserTabsPanel from '../components/BrowserTabsPanel'
|
||||||
import ContactsPanel from '../components/ContactsPanel'
|
import ContactsPanel from '../components/ContactsPanel'
|
||||||
@@ -53,9 +52,10 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const { permission, requestPermission } = useCallNotifications(ringingCall)
|
const { permission, requestPermission } = useCallNotifications(ringingCall)
|
||||||
|
|
||||||
// Meldet SwyxIt! einen eingehenden Anruf, wird die Rufnummer in der
|
// Meldet SwyxIt! einen eingehenden Anruf, geht das Popup auf – mit den
|
||||||
// Adress-Ablage gesucht; gehört sie einem Kunden, geht das Popup mit dessen
|
// Kundendaten, wenn die Rufnummer in der Adress-Ablage einem Kunden gehört,
|
||||||
// Daten auf. Je Anruf nur eine Abfrage – der Snapshot kommt mehrfach.
|
// sonst nur mit der Rufnummer. Je Anruf nur eine Abfrage – der Snapshot
|
||||||
|
// kommt mehrfach.
|
||||||
const [caller, setCaller] = useState<StoredAddress | null>(null)
|
const [caller, setCaller] = useState<StoredAddress | null>(null)
|
||||||
const lookedUpRef = useRef<string | null>(null)
|
const lookedUpRef = useRef<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,11 +72,12 @@ export default function HomePage() {
|
|||||||
let stale = false
|
let stale = false
|
||||||
findCustomerByNumber(number)
|
findCustomerByNumber(number)
|
||||||
.then((match) => {
|
.then((match) => {
|
||||||
if (!stale && match) setCaller(match)
|
if (!stale) setCaller(match ?? { number })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Ablage nicht erreichbar – dann eben kein Popup; der Anruf selbst
|
// Ablage nicht erreichbar – das Popup zeigt dann eben nur die
|
||||||
// wird ohnehin über die Anrufkarte gemeldet.
|
// Rufnummer; annehmen lässt sich der Anruf trotzdem.
|
||||||
|
if (!stale) setCaller({ number })
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
stale = true
|
stale = true
|
||||||
@@ -188,17 +189,6 @@ export default function HomePage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="page__main">
|
<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} />
|
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
<TabPanel id="calls" active={tab === 'calls'}>
|
<TabPanel id="calls" active={tab === 'calls'}>
|
||||||
@@ -349,7 +339,17 @@ export default function HomePage() {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user