Compare commits

..
8 Commits
Author SHA1 Message Date
SvenandClaude Fable 5.1 afd6d03a71 Anruf-Popup auch für Kuriere: Rufnummernsuche findet Kunden und Kuriere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:21:31 +02:00
SvenandClaude Fable 5 ab5e402ee9 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>
2026-08-31 11:07:14 +02:00
SvenandClaude Fable 5 3a86bc1eee Jobs typisiert und erweitert, nächtliche Bereinigung, Kunden-Popup bei Anruf
Job-Klassen in Frontend (Job/JobCustomer/JobCourier/JobTour) und Backend
auf die neue Webhook-Form erweitert (url, finished, service, canceled,
global, phone statt number – alte Schreibweise bleibt verstanden, remark
je Station); Stationen legen ihre Kennung wieder als "id" ab. Adressen
tragen zusätzlich role, csc_id, job_ids und url. Ein Zeitplan löscht
jede Nacht um 0 Uhr Jobs, deren ordertime über vier Wochen zurückliegt.
Meldet SwyxIt! einen Anruf, sucht die Startseite die Rufnummer in der
Ablage (Schreibweisen-Abgleich über die Endziffern) und zeigt bei einem
Kunden ein Popup mit dessen Daten; die Auftrags-URL öffnet als Knopf
einen neuen Tab und schließt das Popup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:40:20 +02:00
SvenandClaude Fable 5 ee81f25942 Umschaltbares Erscheinungsbild: STADTBOTE oder HANSETRANS
Nach dem HANSETRANS Brandbook (2025/10): Grün #68B022 als Akzent,
HANSETRANS Grau als Wortmarken-Farbe, Überschriften gemischt geschrieben
mit negativer Laufweite, Bildmarke (Achteck mit Fahrbahn-Schwung) als
SVG angenähert. Umschalter in der Kopfzeile neben dem Verbindungsstatus,
per data-brand am Wurzelelement; Wahl samt Favicon und Titel bleibt über
Neustarts erhalten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 14:32:58 +02:00
SvenandClaude Fable 5 0fa690a67d Oberfläche entschlackt: Adressdaten ohne Neu-laden und Erklärtexte, Diagnose ohne Rohnachricht
Der Adressdaten-Bereich verliert den Neu-laden-Knopf samt
Telefonbuch-Abgleich und Wartedialog – die Ablage hält sich über
Cache-Push und die Abfragen der Suche aktuell. Anrufen erscheint nur
noch bei bestehender Verbindung zur Tray-App, eine Suche ohne Treffer
zeigt nichts weiter an. Erklärtexte in Adressdaten und Tabs entfernt,
ebenso das Rohnachricht-Formular im Diagnose-Bereich.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:31:13 +02:00
SvenandClaude Fable 5 607cbaecbc Null-Safety-Warnungen der IDE behoben: Methodenreferenzen durch Lambdas ersetzt
Bei einer Methodenreferenz wird der Empfänger zum Parameter des
funktionalen Interfaces, dessen @NonNull-Annotation die Eclipse-Analyse
nicht zusichern kann; als Lambda übernimmt der Parameter die Annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:15:54 +02:00
SvenandClaude Fable 5 a9b40c7e6a Adressdaten-Suche direkt in der MongoDB; Sammlung auf Nummer, Name, Beschreibung verschlankt
Die Sammlung addresses führt für alle Quellen (Webhooks Kunden/Kuriere,
SwyxTray-Telefonbuch) nur noch Rufnummer, Name und Beschreibung; alte
Felder receivedAt/source werden beim Upsert entfernt. GET /api/addresses
sucht mit ?q= als Teilzeichenkette in allen drei Feldern. Der Bereich
"Adressdaten" hält keinen Bestand mehr im Browser: Jede Eingabe fragt
entprellt die Datenbank ab, das Suchfeld startet leer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:07:48 +02:00
SvenandClaude Fable 5 715720fbb3 Erfolgreiche Verbindung merken und automatisch wieder aufbauen; Hinweistexte gekürzt
Die zuletzt erfolgreich verbundene SwyxTray-Adresse landet im localStorage;
beim Laden der Seite wird damit sofort wieder verbunden, manuelles Trennen
vergisst sie. Untertitel, Backend-Hinweis und Webhook-Erklärtexte entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:36:24 +02:00
34 changed files with 1656 additions and 529 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": []
}
]
}
@@ -5,8 +5,11 @@ import de.appcreation.swyxweb.config.WebhookProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
// Zeitpläne wie die nächtliche Job-Bereinigung (JobCleanupService).
@EnableScheduling
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
public class BackendApplication {
@@ -1,6 +1,5 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -20,34 +19,39 @@ import org.springframework.data.mongodb.core.mapping.Document;
* Einträge ohne Rufnummer werden deshalb übergangen ohne sie ließe sich die
* Dublette nicht erkennen.
*
* <p>Alle Quellen liefern dieselben drei Felder, nur unterschiedlich verpackt;
* {@link #manyFrom(JsonNode)} versteht jede dieser Formen:
* <p>{@link #manyFrom(JsonNode)} versteht jede der gelieferten Formen:
* <ul>
* <li>Webhook, ein Eintrag je Aufruf:
* {@code {"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}}</li>
* {@code {"name":"SYSGEN GmbH","number":"+491602107449","description":"Kunde | …",
* "role":"customer","csc_id":100164,"job_ids":[21891263,21891262],"url":"https://…"}}
* die Felder ab {@code role} kann das Fremdsystem auch weglassen</li>
* <li>eine JSON-Liste solcher Einträge</li>
* <li>die Adress-Nachricht der SwyxTray-App:
* <li>die Adress-Nachricht der SwyxTray-App (nur Name, Rufnummer, Beschreibung):
* {@code {"addresses":[{"name":…,"number":…,"description":…}, …],"type":"addresses"}}</li>
* </ul>
*
* @param id Kennung des Dokuments, vergibt die MongoDB
* @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette
* @param receivedAt Eingangszeit im Backend, bei bekannten Einträgen die des
* letzten Eingangs
* @param source woher der Eintrag stammt: {@code kunden}, {@code kuriere},
* {@code webhook} (generischer Webhook) oder {@code swyxtray}
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"
* oder "Kurier | NL Hamburg | PKW | cr 2024"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen"
* oder "Kurier | NL Hamburg | PKW"
* @param role Rolle im Fremdsystem, z. B. "customer"
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param jobIds Kennungen der zugehörigen Jobs ({@code job_ids}), passend
* zur Kennung in der Sammlung {@code jobs}
* @param url Sprungadresse des Fremdsystems zu diesem Eintrag, z. B.
* der TAPI-Wrapper zur Rufnummer
*/
@Document("addresses")
public record AddressEntry(
@Id String id,
String number,
Instant receivedAt,
String source,
String name,
String description) {
String description,
String role,
Integer cscId,
List<Long> jobIds,
String url) {
/**
* Liest aus beliebigem JSON alle Adressdaten-Einträge heraus (siehe die
@@ -72,7 +76,15 @@ public record AddressEntry(
if (!node.isObject()) return;
String number = text(node, "number");
if (number == null) return;
entries.add(new AddressEntry(null, number, null, null, text(node, "name"), text(node, "description")));
entries.add(new AddressEntry(
null,
number,
text(node, "name"),
text(node, "description"),
text(node, "role"),
integer(node, "csc_id"),
longs(node, "job_ids"),
text(node, "url")));
}
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
@@ -82,4 +94,21 @@ public record AddressEntry(
String text = value.stringValue("");
return text.isBlank() ? null : text;
}
/** Ganzzahl der Nutzlast; alles andere wird {@code null}. */
private static Integer integer(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isIntegralNumber() ? value.intValue() : null;
}
/** Liste von Kennungen; fehlt sie oder ist sie keine Liste, wird sie {@code null}. */
private static List<Long> longs(JsonNode node, String field) {
JsonNode value = node.path(field);
if (!value.isArray()) return null;
List<Long> values = new ArrayList<>();
for (JsonNode element : value) {
if (element.isIntegralNumber()) values.add(element.longValue());
}
return values;
}
}
@@ -1,8 +1,8 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.concurrent.Executors;
import jakarta.annotation.PreDestroy;
@@ -50,32 +50,50 @@ public class ArchiveService {
}
/**
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab. Die
* Sammlung ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer
* bekannten Rufnummer wird aktualisiert statt verdoppelt die
* SwyxTray-App schickt bei jeder Cache-Änderung das ganze Telefonbuch.
*
* @param receivedAt Eingangszeit im Backend
* @param source {@code kunden}, {@code kuriere}, {@code webhook}
* (generischer Webhook) oder {@code swyxtray}
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab alle
* Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben
* Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung
* ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten
* Rufnummer wird aktualisiert statt verdoppelt die SwyxTray-App schickt
* bei jeder Cache-Änderung das ganze Telefonbuch.
*/
public void saveAddresses(Instant receivedAt, String source, List<AddressEntry> entries) {
public void saveAddresses(List<AddressEntry> entries) {
if (entries.isEmpty()) return;
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert(
new Query(Criteria.where("number").is(entry.number())),
new Update()
.set("receivedAt", receivedAt)
.set("source", source)
.set("name", entry.name())
.set("description", entry.description()),
.set("description", entry.description())
.set("role", entry.role())
.set("cscId", entry.cscId())
.set("jobIds", entry.jobIds())
.set("url", entry.url())
// Altbestand angleichen: Diese Felder wurden früher
// mitgeschrieben und sollen aus der Sammlung verschwinden.
.unset("receivedAt")
.unset("source"),
AddressEntry.class)));
}
/** Die gespeicherten Adressdaten, nach Namen sortiert. Liest direkt aus der MongoDB. */
public List<AddressEntry> addresses() {
return mongo.find(new Query().with(Sort.by("name")), AddressEntry.class);
/**
* Die gespeicherten Adressdaten, nach Namen sortiert gelesen direkt aus
* der MongoDB, ohne Zwischenstand im Backend oder Browser. Ein Suchbegriff
* wird als Teilzeichenkette in Name, Rufnummer und Beschreibung gesucht,
* ohne Beachtung der Groß-/Kleinschreibung.
*/
public List<AddressEntry> addresses(String query) {
Query find = new Query().with(Sort.by("name"));
if (query != null && !query.isBlank()) {
// Der Begriff ist Text, kein Muster Sonderzeichen werden zitiert.
String regex = Pattern.quote(query.trim());
find.addCriteria(new Criteria().orOperator(
Criteria.where("name").regex(regex, "i"),
Criteria.where("number").regex(regex, "i"),
Criteria.where("description").regex(regex, "i")));
}
return mongo.find(find, AddressEntry.class);
}
/**
@@ -87,6 +105,37 @@ 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
* Schreibzugriffe direkt: Der Aufrufer ist der nächtliche Zeitplan, der auf
* die Anzahl wartet und Fehler selbst behandelt bekommt hier genügt die
* Warnung im Log.
*
* @return wie viele Jobs gelöscht wurden; 0 auch, wenn die Datenbank nicht
* erreichbar war
*/
public long removeJobsBefore(String cutoffOrdertime) {
try {
return mongo.remove(
new Query(Criteria.where("ordertime").lt(cutoffOrdertime)),
JobEntry.class).getDeletedCount();
} catch (RuntimeException e) {
log.warn("MongoDB: alte Jobs nicht gelöscht ({}).", e.getMessage());
return 0;
}
}
/** Legt Anruf-Ereignisse in der Sammlung {@code calls} ab. */
public void saveCalls(List<StoredCall> calls) {
submit("Anrufdaten", () -> calls.forEach(mongo::save));
@@ -0,0 +1,58 @@
package de.appcreation.swyxweb.storage;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* Räumt die Sammlung {@code jobs} auf: Jede Nacht um 0 Uhr werden die Jobs
* gelöscht, deren {@code ordertime} mehr als vier Wochen zurückliegt.
*
* <p>Die {@code ordertime} liegt als ISO-8601-Zeichenkette mit Zeitzonenversatz
* in der Datenbank (z. B. {@code 2026-07-31T16:30:00+02:00}, siehe
* {@link JobEntry}). Verglichen wird als Text gegen eine Grenze im selben
* Format ISO-8601 sortiert als Text richtig; die wenigen Stunden Unschärfe
* durch unterschiedliche Zeitzonenversätze fallen bei einer Vier-Wochen-Grenze
* nicht ins Gewicht. Jobs ohne {@code ordertime} bleiben unangetastet ihr
* Alter ist nicht zu beurteilen.
*/
@Service
public class JobCleanupService {
private static final Logger log = LoggerFactory.getLogger(JobCleanupService.class);
/** „Mehr als vier Wochen in der Vergangenheit" die Grenze der Bereinigung. */
public static final int MAX_AGE_WEEKS = 4;
/** Zeitzone des Zeitplans wie der Grenze: „0 Uhr" heißt 0 Uhr deutscher Zeit. */
public static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private final ArchiveService archive;
public JobCleanupService(ArchiveService archive) {
this.archive = archive;
}
/** Jede Nacht um 0 Uhr; verpasste Läufe (Backend aus) werden nicht nachgeholt. */
@Scheduled(cron = "0 0 0 * * *", zone = "Europe/Berlin")
public void purgeOldJobs() {
String cutoff = cutoffOrdertime(ZonedDateTime.now(ZONE));
long removed = archive.removeJobsBefore(cutoff);
if (removed > 0) {
log.info("Job-Bereinigung: {} Job(s) mit ordertime vor {} gelöscht.", removed, cutoff);
} else {
log.debug("Job-Bereinigung: nichts zu löschen (Grenze {}).", cutoff);
}
}
/** Die Grenze im Format der {@code ordertime}, z. B. {@code 2026-07-31T00:00:00+02:00}. */
public static String cutoffOrdertime(ZonedDateTime now) {
return now.minusWeeks(MAX_AGE_WEEKS)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxxx"));
}
}
@@ -3,6 +3,7 @@ package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -11,6 +12,7 @@ import tools.jackson.databind.ObjectMapper;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
/**
* Ein Job (Kurierauftrag), wie ihn das Fremdsystem über den Webhook schickt
@@ -22,18 +24,27 @@ import org.springframework.data.mongodb.core.mapping.Document;
* {@code modified}) ersetzt also seinen alten Stand.
*
* <p>Die Zeitangaben bleiben die Zeichenketten der Nutzlast
* (z. B. {@code 2026-07-31T16:30:00+02:00}): ISO-8601 sortiert auch als Text
* richtig, und der Zeitzonenversatz des Fremdsystems geht nicht verloren.
* (z. B. {@code 2026-08-13T13:35:27}, teils auch mit Zeitzonenversatz):
* ISO-8601 sortiert auch als Text richtig, und die Schreibweise des
* Fremdsystems geht nicht verloren.
*
* <p>Die Rufnummern hießen in einer älteren Form der Nutzlast {@code number}
* statt {@code phone} beide Schreibweisen werden angenommen.
*
* @param id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs
* @param url Sprungadresse des Fremdsystems zur Auftragsansicht
* @param state Zustand des Jobs im Fremdsystem
* @param ordertime Auftragszeit, z. B. "2026-07-31T16:30:00+02:00"
* @param orderdate Auftragsdatum, z. B. "2026-07-31"
* @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
* @param orderdate Auftragsdatum, z. B. "2026-08-13"
* @param modified letzte Änderung im Fremdsystem
* @param vehicle Fahrzeugart, z. B. "Transporter"
* @param finished wann der Job abgeschlossen wurde, sonst leer
* @param vehicle Fahrzeugart, z. B. "Transporter XL"
* @param service gebuchte Leistung, kann fehlen
* @param canceled ist der Job storniert?
* @param global bundesweite Vermittlung?
* @param customer der beauftragende Kunde
* @param courier der ausführende Kurier
* @param courier der ausführende Kurier, solange keiner zugeteilt ist leer
* @param tours die Stationen des Jobs, per {@code sort} geordnet
*/
@Document("jobs")
@@ -41,11 +52,16 @@ import org.springframework.data.mongodb.core.mapping.Document;
public record JobEntry(
@Id long id,
Instant receivedAt,
String url,
Integer state,
String ordertime,
String orderdate,
String modified,
String finished,
String vehicle,
String service,
Boolean canceled,
Boolean global,
Customer customer,
Courier courier,
List<Tour> tours) {
@@ -56,21 +72,30 @@ public record JobEntry(
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param name Firmenname
* @param hq Niederlassung, z. B. "Bremen"
* @param phone Rufnummer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Customer(@JsonProperty("csc_id") Integer cscId, String name, String hq) {
public record Customer(
@JsonProperty("csc_id") Integer cscId,
String name,
String hq,
@JsonAlias("number") String phone) {
}
/**
* Der ausführende Kurier.
*
* @param crId Kurierkennung des Fremdsystems ({@code cr_id})
* @param sid Kurzkennung, z. B. "B1006"
* @param name Anzeigename
* @param number Rufnummer
* @param crId Kurierkennung des Fremdsystems ({@code cr_id})
* @param sid Kurzkennung, z. B. "B1006"
* @param name Anzeigename
* @param phone Rufnummer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Courier(@JsonProperty("cr_id") Integer crId, String sid, String name, String number) {
public record Courier(
@JsonProperty("cr_id") Integer crId,
String sid,
String name,
@JsonAlias("number") String phone) {
}
/**
@@ -79,29 +104,33 @@ public record JobEntry(
* @param id Kennung des Fremdsystems
* @param sort Reihenfolge innerhalb des Jobs, 1-basiert
* @param state Zustand der Station
* @param mode Art der Station
* @param mode Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung)
* @param comp Firma an der Station
* @param person Ansprechperson
* @param number Rufnummer an der Station, kann fehlen
* @param phone Rufnummer an der Station, kann fehlen
* @param street Straße und Hausnummer
* @param zip Postleitzahl
* @param city Ort
* @param com Bemerkung
* @param remark Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …)
* @param finished wann die Station abgeschlossen wurde, sonst leer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Tour(
Long id,
// Ausdrücklich als "id" ablegen sonst macht Spring Data auch im
// Unterdokument ein "_id" daraus.
@Field("id") Long id,
Integer sort,
Integer state,
String mode,
String comp,
String person,
String number,
@JsonAlias("number") String phone,
String street,
String zip,
String city,
String com,
String remark,
String finished) {
}
@@ -113,7 +142,8 @@ public record JobEntry(
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
JobEntry job = mapper.convertValue(json, JobEntry.class);
return new JobEntry(job.id(), receivedAt, job.state(), job.ordertime(), job.orderdate(),
job.modified(), job.vehicle(), job.customer(), job.courier(), job.tours());
return new JobEntry(job.id(), receivedAt, job.url(), job.state(), job.ordertime(),
job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
}
}
@@ -1,6 +1,5 @@
package de.appcreation.swyxweb.web;
import java.time.Instant;
import java.util.List;
import tools.jackson.databind.JsonNode;
@@ -11,6 +10,7 @@ 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;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -53,13 +53,17 @@ public class AddressController {
if (entries.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
}
archive.saveAddresses(Instant.now(), "swyxtray", entries);
archive.saveAddresses(entries);
return entries.size();
}
/** Die gespeicherten Adressdaten gelesen direkt aus der MongoDB. */
/**
* Die gespeicherten Adressdaten gelesen direkt aus der MongoDB, jede
* Abfrage frisch. Mit {@code ?q=…} wird in der Datenbank gesucht
* (Teilzeichenkette in Name, Rufnummer und Beschreibung).
*/
@GetMapping
public List<AddressEntry> list() {
return archive.addresses();
public List<AddressEntry> list(@RequestParam(name = "q", required = false) String query) {
return archive.addresses(query);
}
}
@@ -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;
}
}
@@ -81,7 +81,7 @@ public class WebhookController {
return accept(payload, token, null);
}
/** Adressdaten der Kunden; abgelegt mit {@code source: "kunden"}. */
/** Adressdaten der Kunden; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKunden(
@RequestBody JsonNode payload,
@@ -89,7 +89,7 @@ public class WebhookController {
return accept(payload, token, "kunden");
}
/** Adressdaten der Kuriere; abgelegt mit {@code source: "kuriere"}. */
/** Adressdaten der Kuriere; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKuriere(
@RequestBody JsonNode payload,
@@ -86,7 +86,7 @@ public class WebhookService {
@PreDestroy
void shutdown() {
heartbeat.shutdownNow();
subscribers.forEach(SseEmitter::complete);
subscribers.forEach(emitter -> emitter.complete());
subscribers.clear();
}
@@ -133,22 +133,21 @@ public class WebhookService {
}
/**
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten (die
* Herkunft wird als {@code source} vermerkt), Jobs sind Jobs. Beim
* generischen Webhook ({@code channel == null}) entscheidet die Nutzlast
* selbst: {@code "type":"job"} ist ein Job, alles andere sind Adressdaten.
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs
* sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet
* die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind
* Adressdaten. Alle Adressdaten landen in derselben Sammlung.
*/
private void archiveByChannel(WebhookEvent event, String channel) {
JsonNode payload = event.payload();
switch (channel == null ? "" : channel) {
case "kunden", "kuriere" ->
archive.saveAddresses(event.receivedAt(), channel, AddressEntry.manyFrom(payload));
case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload));
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
default -> {
if (JobEntry.isJob(payload)) {
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
} else {
archive.saveAddresses(event.receivedAt(), "webhook", AddressEntry.manyFrom(payload));
archive.saveAddresses(AddressEntry.manyFrom(payload));
}
}
}
@@ -359,7 +359,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
String needle = wanted.toLowerCase();
List<MockContact> hits = MOCK_CONTACTS.stream()
.filter(contact -> contact.matches(needle))
.sorted(Comparator.comparing(MockContact::name).thenComparing(MockContact::number))
.sorted(Comparator.comparing((MockContact contact) -> contact.name())
.thenComparing(contact -> contact.number()))
.limit(CONTACT_RESULT_LIMIT)
.toList();
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
@@ -1,13 +1,15 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
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.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
@@ -16,6 +18,7 @@ import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.AddressEntry;
import de.appcreation.swyxweb.storage.ArchiveService;
/**
@@ -44,7 +47,21 @@ class AddressControllerTests {
.andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2));
verify(archive).saveAddresses(any(), eq("swyxtray"), argThat(entries -> entries.size() == 2));
verify(archive).saveAddresses(argThat(entries -> entries.size() == 2));
}
/** Die Suche läuft in der Datenbank der Begriff wird durchgereicht. */
@Test
void listPassesQueryToArchive() throws Exception {
when(archive.addresses("muster"))
.thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null, null, null, null, null)));
mvc.perform(get("/api/addresses").param("q", "muster"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Muster GmbH"))
.andExpect(jsonPath("$[0].number").value("+493012345"));
verify(archive).addresses("muster");
}
@Test
@@ -27,14 +27,34 @@ class AddressEntryTests {
@Test
void readsSingleWebhookEntry() {
List<AddressEntry> entries = parse("""
{"name":"SYSGEN GmbH","number":"+49421409660",
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"}""");
{"name":"SYSGEN GmbH","number":"+491602107449",
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen",
"role":"customer","csc_id":100164,"job_ids":[21891263,21891262],
"url":"https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449"}""");
assertThat(entries).hasSize(1);
AddressEntry entry = entries.getFirst();
assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
assertThat(entry.number()).isEqualTo("+49421409660");
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164");
assertThat(entry.number()).isEqualTo("+491602107449");
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen");
assertThat(entry.role()).isEqualTo("customer");
assertThat(entry.cscId()).isEqualTo(100164);
assertThat(entry.jobIds()).containsExactly(21891263L, 21891262L);
assertThat(entry.url())
.isEqualTo("https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449");
}
/** Die Felder ab {@code role} darf das Fremdsystem auch weglassen. */
@Test
void extraFieldsStayNullWhenAbsent() {
List<AddressEntry> entries = parse("""
{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}""");
AddressEntry entry = entries.getFirst();
assertThat(entry.role()).isNull();
assertThat(entry.cscId()).isNull();
assertThat(entry.jobIds()).isNull();
assertThat(entry.url()).isNull();
}
/** Kuriere und Jobs kommen analog auch als Liste. */
@@ -58,7 +78,7 @@ class AddressEntryTests {
"type":"addresses"}""");
assertThat(entries).hasSize(2);
assertThat(entries).extracting(AddressEntry::number).containsExactly("7587", "5215");
assertThat(entries).extracting(entry -> entry.number()).containsExactly("7587", "5215");
}
/**
@@ -0,0 +1,32 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.Test;
import de.appcreation.swyxweb.storage.JobCleanupService;
/**
* Die nächtliche Job-Bereinigung vergleicht die {@code ordertime} als Text
* hier wird die Grenze und der Vergleich gegen das Format der Nutzlast geprüft.
*/
class JobCleanupServiceTests {
@Test
void cutoffLiesFourWeeksBackInOrdertimeFormat() {
ZonedDateTime now = ZonedDateTime.of(2026, 8, 28, 0, 0, 0, 0, JobCleanupService.ZONE);
// Vier Wochen vor dem 28.08. ist der 31.07.; im Sommer gilt +02:00.
assertThat(JobCleanupService.cutoffOrdertime(now)).isEqualTo("2026-07-31T00:00:00+02:00");
}
@Test
void ordertimeStringsCompareChronologically() {
String cutoff = "2026-07-31T00:00:00+02:00";
assertThat("2026-07-30T18:14:02+02:00").isLessThan(cutoff);
assertThat("2026-08-01T08:00:00+02:00").isGreaterThan(cutoff);
}
}
@@ -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());
}
}
@@ -13,7 +13,9 @@ import de.appcreation.swyxweb.storage.JobEntry;
/**
* {@link JobEntry} wird aus der Webhook-Nutzlast mit {@code "type":"job"}
* gefüllt samt Kunde, Kurier und Stationen.
* gefüllt samt Kunde, Kurier und Stationen. Eine ältere Form der Nutzlast
* nannte die Rufnummern {@code number} statt {@code phone}; auch sie wird
* weiterhin verstanden.
*/
class JobEntryTests {
@@ -22,21 +24,30 @@ class JobEntryTests {
private static final String SAMPLE = """
{
"type": "job",
"id": 21891253,
"state": 2,
"ordertime": "2026-07-31T16:30:00+02:00",
"orderdate": "2026-07-31",
"modified": "2026-07-30T18:14:02+02:00",
"vehicle": "Transporter",
"customer": { "csc_id": 868374, "name": "B+M isol nord GmbH", "hq": "Bremen" },
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006", "number": "+4917xxxxxxx" },
"id": 21891263,
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
"state": 9,
"ordertime": "2026-08-13T13:35:27",
"orderdate": "2026-08-13",
"modified": "2026-08-13T13:35:27",
"finished": null,
"vehicle": "Transporter XL",
"service": null,
"canceled": false,
"global": false,
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
"phone": "+491602107449" },
"courier": null,
"tours": [
{ "id": 22396738, "sort": 1, "state": 1, "mode": "", "comp": "B+M isol nord GmbH",
"person": "", "number": "+4942037010xx", "street": "Löwenhof 2", "zip": "28844",
"city": "Weyhe-Dreye", "com": "", "finished": "2026-07-30T18:13:11+02:00" },
{ "id": 22396739, "sort": 2, "state": 1, "mode": "", "comp": "Reichspräsident Ebert Kaserne",
"person": "", "number": null, "street": "Osdorfer Landstr. 365", "zip": "22589",
"city": "Hamburg", "com": "", "finished": "2026-07-30T18:13:51+02:00" }
{ "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
"person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
"remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
"zip": "28327", "city": "Bremen", "finished": null },
{ "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
"comp": "Super Micro Computer B.V.", "person": null, "phone": null, "com": null,
"remark": "Abholreferenz: 8801420234\\nAnzahl an Paletten: 8",
"street": "Het Sterrenbeeld 12-16", "zip": "5215", "city": "'s-Hertogenbosch",
"finished": null }
]
}""";
@@ -49,33 +60,60 @@ class JobEntryTests {
@Test
void fillsAllFieldsFromSample() {
Instant receivedAt = Instant.parse("2026-08-26T10:00:00Z");
Instant receivedAt = Instant.parse("2026-08-28T10:00:00Z");
JsonNode json = mapper.readTree(SAMPLE);
JobEntry job = JobEntry.from(json, mapper, receivedAt);
assertThat(job.id()).isEqualTo(21891253L);
assertThat(job.id()).isEqualTo(21891263L);
assertThat(job.receivedAt()).isEqualTo(receivedAt);
assertThat(job.state()).isEqualTo(2);
assertThat(job.ordertime()).isEqualTo("2026-07-31T16:30:00+02:00");
assertThat(job.orderdate()).isEqualTo("2026-07-31");
assertThat(job.vehicle()).isEqualTo("Transporter");
assertThat(job.url()).isEqualTo("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263");
assertThat(job.state()).isEqualTo(9);
assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
assertThat(job.orderdate()).isEqualTo("2026-08-13");
assertThat(job.finished()).isNull();
assertThat(job.vehicle()).isEqualTo("Transporter XL");
assertThat(job.service()).isNull();
assertThat(job.canceled()).isFalse();
assertThat(job.global()).isFalse();
assertThat(job.customer().cscId()).isEqualTo(868374);
assertThat(job.customer().name()).isEqualTo("B+M isol nord GmbH");
assertThat(job.customer().cscId()).isEqualTo(100164);
assertThat(job.customer().name()).isEqualTo("SYSGEN GmbH");
assertThat(job.customer().hq()).isEqualTo("Bremen");
assertThat(job.customer().phone()).isEqualTo("+491602107449");
assertThat(job.courier().crId()).isEqualTo(14116);
assertThat(job.courier().sid()).isEqualTo("B1006");
assertThat(job.courier().number()).isEqualTo("+4917xxxxxxx");
// Noch kein Kurier zugeteilt.
assertThat(job.courier()).isNull();
assertThat(job.tours()).hasSize(2);
JobEntry.Tour first = job.tours().getFirst();
assertThat(first.id()).isEqualTo(22396738L);
assertThat(first.id()).isEqualTo(22396758L);
assertThat(first.sort()).isEqualTo(1);
assertThat(first.street()).isEqualTo("Löwenhof 2");
assertThat(first.finished()).isEqualTo("2026-07-30T18:13:11+02:00");
assertThat(first.mode()).isEqualTo("del");
assertThat(first.person()).isEqualTo("Frau Hoffmann");
assertThat(first.phone()).isEqualTo("+49421409660");
assertThat(first.remark()).contains("Hebebühne benötigt.");
assertThat(first.street()).isEqualTo("Am Hallacker 48");
// Fehlende Rufnummer der zweiten Station bleibt null.
assertThat(job.tours().get(1).number()).isNull();
assertThat(job.tours().get(1).phone()).isNull();
assertThat(job.tours().get(1).city()).isEqualTo("'s-Hertogenbosch");
}
/** Die ältere Form nannte die Rufnummern {@code number} statt {@code phone}. */
@Test
void acceptsLegacyNumberFields() {
JsonNode json = mapper.readTree("""
{
"type": "job",
"id": 21891253,
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006",
"number": "+4917xxxxxxx" },
"tours": [ { "id": 22396738, "sort": 1, "number": "+4942037010xx" } ]
}""");
JobEntry job = JobEntry.from(json, mapper, Instant.parse("2026-08-28T10:00:00Z"));
assertThat(job.courier().phone()).isEqualTo("+4917xxxxxxx");
assertThat(job.tours().getFirst().phone()).isEqualTo("+4942037010xx");
}
}
@@ -1,9 +1,7 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -12,7 +10,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
@@ -114,12 +111,14 @@ class WebhookControllerTests {
.andExpect(status().isOk());
assertThat(service.history())
.extracting(WebhookEvent::channel)
.extracting(event -> event.channel())
.containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
verify(archive).saveAddresses(any(), eq("kunden"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(argThat(entries ->
entries.size() == 1 && "SYSGEN GmbH".equals(entries.getFirst().name())));
verify(archive).saveAddresses(argThat(entries ->
entries.size() == 1 && "Rainer Peters (HH1003)".equals(entries.getFirst().name())));
verify(archive).saveJob(argThat(job -> job.id() == 4711));
}
@@ -132,7 +131,7 @@ class WebhookControllerTests {
.andExpect(status().isOk());
assertThat(service.history().getFirst().channel()).isNull();
verify(archive).saveAddresses(any(), eq("webhook"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(argThat(entries -> entries.size() == 1));
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { pickCaller, sameNumber, type StoredAddress } from './addresses'
/**
* SwyxIt! meldet Rufnummern mit Amtsholung ("001602107449"), die Webhooks
* liefern sie international ("+491602107449") der Abgleich muss beide
* Schreibweisen als denselben Anschluss erkennen.
*/
describe('sameNumber', () => {
it('erkennt dieselbe Nummer trotz unterschiedlicher Schreibweise', () => {
expect(sameNumber('+491602107449', '001602107449')).toBe(true)
expect(sameNumber('+491602107449', '01602107449')).toBe(true)
expect(sameNumber('+49421409660', '0421409660')).toBe(true)
expect(sameNumber('+491602107449', '+491602107449')).toBe(true)
})
it('unterscheidet verschiedene Anschlüsse', () => {
expect(sameNumber('+491602107449', '+491602107440')).toBe(false)
expect(sameNumber('+49421409660', '+494212409661')).toBe(false)
})
it('verlangt bei kurzen Nummern (Durchwahlen) die volle Übereinstimmung', () => {
expect(sameNumber('7587', '7587')).toBe(true)
expect(sameNumber('7587', '587')).toBe(false)
// Eine Durchwahl ist nicht "dieselbe Nummer" wie ein externer Anschluss.
expect(sameNumber('7449', '+491602107449')).toBe(false)
})
it('liefert ohne Ziffern kein Ergebnis', () => {
expect(sameNumber('', '+491602107449')).toBe(false)
expect(sameNumber('unbekannt', '+491602107449')).toBe(false)
})
})
describe('pickCaller', () => {
const entries: StoredAddress[] = [
{ number: '5215', name: 'Abdul, Rokhsareh', description: 'Globales Telefonbuch' },
{
number: '+491602107449',
name: 'SYSGEN GmbH',
description: 'Kunde | SYSGEN, SYSTEME UND | NL Bremen',
role: 'customer',
cscId: 100164,
url: 'https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449',
},
{
number: '+49171677xxxx',
name: 'Rainer Peters (HH1003)',
role: 'courier',
jobIds: [21891233],
},
]
it('findet den Kunden zur gemeldeten Rufnummer', () => {
const match = pickCaller(entries, '001602107449')
expect(match?.name).toBe('SYSGEN GmbH')
expect(match?.url).toContain('tapi_wrapper')
})
it('findet auch den Kurier zur gemeldeten Rufnummer', () => {
const match = pickCaller(entries, '+49171677xxxx')
expect(match?.name).toBe('Rainer Peters (HH1003)')
expect(match?.jobIds).toEqual([21891233])
})
it('übergeht Telefonbuch-Einträge (ohne Rolle)', () => {
expect(pickCaller(entries, '5215')).toBeNull()
})
it('bevorzugt bei doppelter Rufnummer den Kunden vor dem Kurier', () => {
const both: StoredAddress[] = [
{ number: '+49404711', name: 'Kurier', role: 'courier' },
{ number: '+49404711', name: 'Kunde', role: 'customer' },
]
expect(pickCaller(both, '+49404711')?.name).toBe('Kunde')
})
it('liefert null, wenn keine Rufnummer passt', () => {
expect(pickCaller(entries, '+49404711')).toBeNull()
})
})
+88
View File
@@ -1,5 +1,93 @@
import type { Contact } from './swyx/protocol'
/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
export interface StoredAddress {
number?: string | null
name?: string | null
description?: string | null
/** Rolle im Fremdsystem, z. B. "customer". */
role?: string | null
/** Kundenkennung des Fremdsystems (`csc_id`). */
cscId?: number | null
/** Kennungen der zugehörigen Jobs. */
jobIds?: number[] | null
/** Sprungadresse des Fremdsystems zu diesem Eintrag, z. B. zum Auftrag. */
url?: string | null
}
/**
* Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab
* den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem
* SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in
* Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten.
*/
export async function fetchAddressEntries(query = ''): Promise<StoredAddress[]> {
const wanted = query.trim()
const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses'
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`)
}
return (await response.json()) as StoredAddress[]
}
/** Wie {@link fetchAddressEntries}, verengt auf die Felder der Adressanzeige. */
export async function fetchAddresses(query = ''): Promise<Contact[]> {
const entries = await fetchAddressEntries(query)
return entries.map((entry) => ({
name: entry.name ?? undefined,
number: entry.number ?? undefined,
description: entry.description ?? undefined,
}))
}
/** Nur die Ziffern, ohne führende Nullen (Amtsholung, nationale Schreibweise). */
function significantDigits(number: string): string {
return number.replace(/\D/g, '').replace(/^0+/, '')
}
/**
* Meinen zwei Rufnummern denselben Anschluss? `+491602107449`, `01602107449`
* und die von SwyxIt! gemeldete Form mit Amtsholung `001602107449`
* unterscheiden sich nur in Vorwahl-Schreibweise und führenden Nullen
* verglichen werden deshalb die letzten (bis zu neun) signifikanten Ziffern.
* Kurze Nummern (interne Durchwahlen) müssen ganz übereinstimmen.
*/
export function sameNumber(a: string, b: string): boolean {
const left = significantDigits(a)
const right = significantDigits(b)
if (!left || !right) return false
const length = Math.min(left.length, right.length)
if (length < 6) return left === right
const tail = Math.min(9, length)
return left.slice(-tail) === right.slice(-tail)
}
/**
* Der erste Kunde oder Kurier aus `entries`, dessen Rufnummer passt Einträge
* ohne Rolle (SwyxTray-Telefonbuch) zählen nicht. Passen beide Rollen, gewinnt
* der Kunde.
*/
export function pickCaller(entries: StoredAddress[], number: string): StoredAddress | null {
const withRole = (role: string) =>
entries.find(
(entry) => entry.role === role && entry.number && sameNumber(entry.number, number),
)
return withRole('customer') ?? withRole('courier') ?? null
}
/**
* Sucht in der Ablage den Kunden oder Kurier zur Rufnummer eines Anrufs. Die
* Datenbank sucht als Teilzeichenkette abgefragt werden deshalb die letzten
* Ziffern, das genaue Passen prüft {@link sameNumber} hier im Browser.
*/
export async function findCallerByNumber(number: string): Promise<StoredAddress | null> {
const digits = significantDigits(number)
if (!digits) return null
const entries = await fetchAddressEntries(digits.slice(-9))
return pickCaller(entries, number)
}
/**
* Meldet den Adress-Cache der SwyxTray-App an das Backend, das die Einträge in
* der MongoDB ablegt (Aktualisierung statt Verdopplung, siehe ArchiveService).
+50
View File
@@ -0,0 +1,50 @@
/**
* Umschaltbares Erscheinungsbild (CI/CD): STADTBOTE (Vorgabe) oder HANSETRANS.
*
* Beide Vorgaben stammen aus dem HANSETRANS-Brandbook (Stand 2025/10): gleiche
* Schriftfamilie (Red Hat Display/Text), aber eigene Farben, Wortmarke und
* Bildmarke je Marke. Die Umschaltung läuft über `data-brand` am Wurzelelement
* die Farb-Token dazu stehen in der index.css.
*/
export type Brand = 'stadtbote' | 'hansetrans'
const STORAGE_KEY = 'swyxweb.brand'
export function loadBrand(): Brand {
try {
return localStorage.getItem(STORAGE_KEY) === 'hansetrans' ? 'hansetrans' : 'stadtbote'
} catch {
// Privater Modus o. Ä. dann eben immer die Vorgabe.
return 'stadtbote'
}
}
const TITLES: Record<Brand, string> = {
stadtbote: 'SwyxWeb · STADTBOTE',
hansetrans: 'SwyxWeb · HANSETRANS',
}
/**
* Favicons als Daten-URI: das STADTBOTE-Signet (roter Ring mit „S",
* Markenrot #CA0D38) und die HANSETRANS-Bildmarke (grünes Achteck #68B022
* mit „Fahrbahn"-Schwung), jeweils wie in den Signet-Komponenten.
*/
const FAVICONS: Record<Brand, string> = {
stadtbote:
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20d='M%2055%2014.4%20A%2036%2036%200%201%200%2081.8%2033.1'%20fill='none'%20stroke='%23CA0D38'%20stroke-width='13'/%3E%3Ctext%20x='50'%20y='52'%20text-anchor='middle'%20dominant-baseline='central'%20font-family='Arial,Helvetica,sans-serif'%20font-weight='bold'%20font-size='54'%20fill='%23CA0D38'%3ES%3C/text%3E%3C/svg%3E",
hansetrans:
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20fill='%2368B022'%20fill-rule='evenodd'%20d='M29.3%200%20H70.7%20L100%2029.3%20V70.7%20L70.7%20100%20H29.3%20L0%2070.7%20V29.3%20Z%20M4%2052%20C38%2046%2070%2040%2096%2031%20C66%2044%2036%2054%206%2062%20Z%20M10%2076%20C46%2066%2074%2052%2096%2035%20C76%2056%2048%2072%2016%2084%20Z'/%3E%3C/svg%3E",
}
/** Stellt das Erscheinungsbild um und merkt es sich für den nächsten Start. */
export function applyBrand(brand: Brand): void {
document.documentElement.dataset.brand = brand
document.title = TITLES[brand]
const icon = document.querySelector<HTMLLinkElement>("link[rel='icon']")
if (icon) icon.href = FAVICONS[brand]
try {
localStorage.setItem(STORAGE_KEY, brand)
} catch {
// absichtlich still dann gilt die Wahl nur für diese Sitzung.
}
}
@@ -185,10 +185,6 @@ export default function BrowserTabsPanel({ active, disabled, busy, onList, onOpe
</div>
</form>
<p className="note">
Die Tabs gehören zum Firefox auf dem Rechner der SwyxTray-App. Ohne verbundenes Plugin
beantwortet die App die drei Kommandos nach etwa fünf Sekunden mit einem Fehler.
</p>
{hint && <p className="note note--error">{hint}</p>}
{outcome && <p className="note note--success">{outcome}</p>}
</>
+123 -221
View File
@@ -1,262 +1,164 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { describeContact, type Contact } from '../swyx/protocol'
import {
filterContacts,
sortContacts,
type Directory,
type DirectoryProgress,
type SweepOptions,
} from '../swyx/directory'
import LoadingDialog from './LoadingDialog'
import { fetchAddresses } from '../addresses'
interface Props {
/** Panel ist sichtbar erst dann wird geladen. */
/** Panel ist sichtbar erst dann wird abgefragt. */
active: boolean
/** Keine Verbindung zur SwyxTray-App Anrufen entfällt. */
disabled: boolean
/**
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
* angezeigt, sondern ist das Signal, die Ablage neu abzufragen das Backend
* hat ihn gerade hineingeschrieben.
*/
cache: Contact[] | null
onLoad: (options: SweepOptions) => Promise<Directory | null>
onDial: (number: string) => void
}
const STORAGE_FILTER = 'swyxweb.contacts.filter'
/** So lange bekommt das Backend Zeit, den Telefonbuch-Push wegzuschreiben. */
const ARCHIVE_WRITE_DELAY_MS = 1500
function stored(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
// Privater Modus o. Ä. dann eben ohne Gedächtnis.
return ''
}
}
function remember(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// absichtlich still
}
}
/** Entprellung der Eingabe erst dann geht die Abfrage an die Datenbank. */
const SEARCH_DEBOUNCE_MS = 300
/**
* Adressdaten des Swyx-Clients der **gesamte** Bestand des globalen
* Telefonbuchs.
* Adressdaten aus der **Adress-Ablage des Backends** (MongoDB-Sammlung
* `addresses`) dem zusammengeführten Bestand aus den Webhooks (Kunden,
* Kuriere) und dem Telefonbuch des Swyx-Clients.
*
* Drei Wege, in dieser Reihenfolge:
* Es gibt keinen Bestand im Browser: Jede Eingabe im Suchfeld fragt
* entprellt die Datenbank ab, gesucht wird dort als Teilzeichenkette in
* Name, Rufnummer und Beschreibung. Angezeigt wird immer das frische
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
* SwyxTray-App.
*
* 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst
* schickt. Er liegt dann schon vor, bevor der Bereich geöffnet wird kein
* Laden, kein Wartedialog.
* 2. Sonst beim Öffnen das Kommando `addresses`: derselbe Cache auf Anfrage.
* 3. Kennt die App das Kommando nicht oder ist der Cache leer, werden rund 110
* Einzelabfragen zusammengesetzt. Das dauert einige Sekunden und läuft hinter
* einem Wartedialog.
*
* Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage.
* Das Telefonbuch der App fließt über den **Cache-Push** beim Verbinden
* (Protokoll 8) in die Ablage die Startseite meldet ihn ans Backend; das
* Panel fragt die Ablage danach neu ab.
*/
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
const [directory, setDirectory] = useState<Directory | null>(null)
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
export default function ContactsPanel({ active, disabled, cache, onDial }: Props) {
// Das jeweils letzte Abfrageergebnis kein Bestand, nur die Anzeige.
const [results, setResults] = useState<Contact[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
const [filter, setFilter] = useState('')
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden.
const loadedRef = useRef(false)
const abortRef = useRef<AbortController | null>(null)
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben.
const loadIdRef = useRef(0)
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
// mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge.
const queryIdRef = useRef(0)
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
// (Cache-Push) ohne den Effekt neu anzustoßen.
const filterRef = useRef(filter)
filterRef.current = filter
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
const supersede = useCallback(() => {
loadIdRef.current += 1
abortRef.current?.abort()
abortRef.current = null
setProgress(null)
/** Fragt die Ablage mit dem aktuellen Suchbegriff ab. */
const search = useCallback(async () => {
const ticket = ++queryIdRef.current
try {
const entries = await fetchAddresses(filterRef.current)
if (ticket !== queryIdRef.current) return
setResults(entries)
setLoadError(null)
} catch (e) {
if (ticket !== queryIdRef.current) return
setLoadError(e instanceof Error ? e.message : String(e))
}
}, [])
const load = useCallback(async () => {
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
supersede()
const ticket = loadIdRef.current
const controller = new AbortController()
abortRef.current = controller
// Jede Eingabe fragt die Datenbank ab entprellt; die erste Abfrage nach
// dem Öffnen läuft sofort. Beim erneuten Öffnen des Bereichs ebenfalls
// frisch abfragen, damit nie ein alter Stand stehen bleibt.
useEffect(() => {
if (!active) return
const timer = setTimeout(() => void search(), results === null ? 0 : SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
// `results` absichtlich nicht in den Abhängigkeiten: Es würde nach jeder
// Antwort eine weitere Abfrage anstoßen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, filter, search])
setProgress({ done: 0, total: 0, found: 0 })
try {
const result = await onLoad({
signal: controller.signal,
onProgress: (next) => {
if (ticket === loadIdRef.current) setProgress(next)
},
})
if (ticket !== loadIdRef.current) return
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
if (result) setDirectory(result)
} finally {
if (ticket === loadIdRef.current) {
abortRef.current = null
setProgress(null)
}
}
}, [onLoad, supersede])
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist
// dagegen keine Antwort dann bleibt es beim Laden weiter unten.
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
// nach einer kurzen Schreibfrist die Ablage neu abfragen.
useEffect(() => {
if (!cache || cache.length === 0) return
loadedRef.current = true
supersede()
setDirectory({
contacts: sortContacts(cache),
source: 'cache',
queries: 0,
complete: true,
aborted: false,
})
}, [cache, supersede])
useEffect(() => {
if (disabled) {
// Ohne Verbindung ist der Bestand veraltet; er wird beim nächsten Mal neu geholt.
supersede()
loadedRef.current = false
setDirectory(null)
return
}
if (!active || loadedRef.current) return
loadedRef.current = true
void load()
}, [active, disabled, load, supersede])
// Ein laufender Durchlauf soll nicht weiterfragen, wenn die Seite verschwindet.
useEffect(() => () => abortRef.current?.abort(), [])
const visible = useMemo(
() => (directory ? filterContacts(directory.contacts, filter) : []),
[directory, filter],
)
const loading = progress !== null
const timer = setTimeout(() => void search(), ARCHIVE_WRITE_DELAY_MS)
return () => clearTimeout(timer)
}, [cache, search])
return (
<>
<div className="card__header">
<h2>Adressdaten</h2>
<button
type="button"
className="button button--ghost"
onClick={() => void load()}
disabled={disabled || loading}
>
{loading ? 'Lädt …' : 'Neu laden'}
</button>
<h2>Adressdaten</h2>
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
und würde als direktes Kind der Karte einer Spalte in die *Höhe*
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
die Breite, wie in allen anderen Bereichen auch. */}
<div className="row">
<label className="field">
<span className="field__label">Suchen</span>
<input
className="field__input"
type="search"
value={filter}
spellCheck={false}
autoComplete="off"
placeholder="Name, Rufnummer oder Kürzel"
onChange={(e) => setFilter(e.target.value)}
/>
</label>
</div>
{disabled ? (
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
{loadError && <p className="note note--error">{loadError}</p>}
{results === null ? (
!loadError && <p className="note">Adressdaten werden abgefragt </p>
) : (
<>
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
und würde als direktes Kind der Karte einer Spalte in die *Höhe*
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
die Breite, wie in allen anderen Bereichen auch. */}
<div className="row">
<label className="field">
<span className="field__label">Filtern</span>
<input
className="field__input"
type="search"
value={filter}
spellCheck={false}
autoComplete="off"
placeholder="Name, Rufnummer oder Kürzel"
onChange={(e) => {
setFilter(e.target.value)
remember(STORAGE_FILTER, e.target.value.trim())
}}
/>
</label>
</div>
<p className="note">
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
</p>
{directory === null ? (
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
) : (
<>
{results.length === 0 ? (
!filter.trim() && (
<p className="note">
{filter.trim()
? `${visible.length} von ${directory.contacts.length} Einträgen`
: `${directory.contacts.length} Einträge`}
{directory.source === 'cache'
? ' · aus dem Adress-Cache der App'
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`}
Die Ablage ist noch leer sie füllt sich über die Webhooks und über das
Telefonbuch der SwyxTray-App.
</p>
{!directory.complete && (
<p className="note note--error">
{directory.aborted
? 'Abgebrochen der Bestand ist unvollständig.'
: 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '}
Neu laden" versucht es erneut.
</p>
)}
{visible.length === 0 ? (
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
) : (
<ul className="contacts">
{visible.map((contact, index) => (
// Namen sind nicht eindeutig dieselbe Person kommt mit
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
<li
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
className="contacts__item"
)
) : (
<ul className="contacts">
{results.map((contact, index) => (
// Namen sind nicht eindeutig dieselbe Person kommt mit
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
<li
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
className="contacts__item"
>
<span className="contacts__info">
<span className="contacts__name">{describeContact(contact)}</span>
{contact.description && (
<span className="contacts__description">{contact.description}</span>
)}
</span>
{contact.number && <span className="contacts__number">{contact.number}</span>}
{/* Ohne Verbindung zur Tray-App gibt es nichts zu wählen
der Knopf erscheint dann gar nicht erst. */}
{contact.number && !disabled && (
<button
type="button"
className="button button--small"
onClick={() => onDial(contact.number!)}
>
<span className="contacts__info">
<span className="contacts__name">{describeContact(contact)}</span>
{contact.description && (
<span className="contacts__description">{contact.description}</span>
)}
</span>
{contact.number && <span className="contacts__number">{contact.number}</span>}
{contact.number && (
<button
type="button"
className="button button--small"
onClick={() => onDial(contact.number!)}
>
Anrufen
</button>
)}
</li>
))}
</ul>
)}
</>
Anrufen
</button>
)}
</li>
))}
</ul>
)}
</>
)}
<p className="note">
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere
Abfrage.
</p>
{loading && (
<LoadingDialog
title="Adressdaten werden geladen"
done={progress.done}
total={progress.total}
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
onCancel={() => abortRef.current?.abort()}
/>
)}
</>
)
}
@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react'
import type { StoredAddress } from '../addresses'
import { fetchJob } from '../jobs'
interface Props {
/** Der Kunde oder Kurier 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: zeigt die Daten des Anrufers aus der
* Adress-Ablage (Kunde oder Kurier), dazu je Job-Kennung des Eintrags
* (`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, 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.
useEffect(() => {
closeRef.current?.focus()
}, [])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
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">
<p className="dialog__status">Eingehender Anruf von</p>
<h2 className="dialog__title" id="caller-popup-title">
{customer.name ?? customer.number}
</h2>
{customer.number && <p className="note">{customer.number}</p>}
{customer.description && <p className="note">{customer.description}</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">
{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
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,18 @@
/**
* Die HANSETRANS-Bildmarke aus dem Brandbook: ein geöffnetes Achteck mit
* „Fahrbahn"-Schwung. Als Inline-SVG angenähert, weil das Brandbook keine
* Vektordaten enthält; die Farbe kommt über currentColor vom Umfeld, der
* Schwung ist ein echtes Loch (evenodd) und zeigt den Seitenhintergrund
* so, wie das Brandbook die Marke auf hellen und dunklen Flächen zeigt.
*/
export default function HansetransSignet({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fillRule="evenodd"
d="M29.3 0 H70.7 L100 29.3 V70.7 L70.7 100 H29.3 L0 70.7 V29.3 Z M4 52 C38 46 70 40 96 31 C66 44 36 54 6 62 Z M10 76 C46 66 74 52 96 35 C76 56 48 72 16 84 Z"
/>
</svg>
)
}
@@ -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>
)
}
+1 -10
View File
@@ -65,10 +65,7 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
{error && <p className="note note--error">{error}</p>}
{events.length === 0 ? (
<p className="note">
Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '}
<code>POST /api/webhook</code>; sie erscheinen hier ohne Zutun.
</p>
<p className="note">Noch keine Nachricht eingegangen.</p>
) : (
<ul className="webhook">
{events.map((event) => (
@@ -83,12 +80,6 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
))}
</ul>
)}
<p className="note">
Der Webhook nimmt beliebiges JSON an Objekt wie Liste und zeigt es hier unverändert.
Der Verlauf liegt im Speicher des Backends und ist nach dessen Neustart leer; Leeren"
verwirft ihn auch dort.
</p>
</>
)
}
+30
View File
@@ -6,6 +6,36 @@ export const DEFAULT_WS_PATH = '/ws'
export const DEFAULT_WS_URL =
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
const SAVED_WS_URL_KEY = 'swyxweb.wsUrl'
/**
* Zuletzt erfolgreich verbundene Adresse. localStorage kann fehlen oder gesperrt
* sein (Privatmodus, Browser-Einstellung) dann wird schlicht nichts gemerkt.
*/
export function loadSavedWsUrl(): string | null {
try {
return localStorage.getItem(SAVED_WS_URL_KEY)
} catch {
return null
}
}
export function saveWsUrl(url: string): void {
try {
localStorage.setItem(SAVED_WS_URL_KEY, url)
} catch {
// Ohne Speicher kein Wiederverbinden nach Neustart mehr geht nicht verloren.
}
}
export function clearSavedWsUrl(): void {
try {
localStorage.removeItem(SAVED_WS_URL_KEY)
} catch {
// Siehe saveWsUrl.
}
}
export interface ClientConfig {
websocketUrl: string
host: string
+102 -80
View File
@@ -1,10 +1,12 @@
/*
* Farb- und Schriftvorgaben aus dem STADTBOTE-Styleguide (HANSETRANS Brandbook):
* Primär Rot #CA0D38 (Pantone 185 C) und Grau #7C7F7E (Pantone 424 C),
* Off-Black #222222 für Fließtext, Grauabstufungen als Flächen.
* Schrift: Red Hat Display (Überschriften, Versalien, Laufweite 4 %) und
* Red Hat Text (Fließtext). Buttons: primär vollflächig, sekundär mit Rahmen,
* Kästen und Linien mit leichter Abrundung.
* Farb- und Schriftvorgaben aus dem HANSETRANS Brandbook. Vorgabe ist das
* STADTBOTE-Erscheinungsbild: Primär Rot #CA0D38 (Pantone 185 C) und Grau
* #7C7F7E (Pantone 424 C), Off-Black #222222 für Fließtext, Grauabstufungen
* als Flächen. Schrift: Red Hat Display (Überschriften, Versalien, Laufweite
* 4 %) und Red Hat Text (Fließtext). Buttons: primär vollflächig, sekundär mit
* Rahmen, Kästen und Linien mit leichter Abrundung.
* Das HANSETRANS-Erscheinungsbild wird weiter unten über data-brand
* am Wurzelelement zugeschaltet.
*/
:root {
--bg: #ffffff;
@@ -20,6 +22,9 @@
--brand-strong: #a30a2d;
--brand-soft: rgb(202 13 56 / 8%);
--accent: #ca0d38;
/* Kopfzeile: Bild- und Wortmarke; bei STADTBOTE beide im Markenrot. */
--logo-mark: #ca0d38;
--logo-word: #ca0d38;
--ok: #2f9e6e;
--warn: #b98a1c;
--err: #d92b1f;
@@ -43,6 +48,32 @@
}
}
/*
* HANSETRANS-Erscheinungsbild (HANSETRANS Brandbook 2025/10, Styleguide):
* HANSETRANS Grün #68B022 (Pantone 361 C) als zentrale Akzentfarbe,
* Dunkelgrün #3D821C, HANSETRANS Grau #4B4A4D als Farbe der Wortmarke,
* Off-Black #222222 für Fließtext, Grauabstufungen #B4B4B5/#DCDCDD/#F6F6F6.
* Umgeschaltet über data-brand am Wurzelelement (Bereich „Verbindung").
*/
:root[data-brand='hansetrans'] {
--brand: #68b022;
--brand-strong: #3d821c;
--brand-soft: rgb(104 176 34 / 10%);
--accent: #68b022;
--text-muted: #4b4a4d;
--logo-mark: #68b022;
--logo-word: #4b4a4d;
}
@media (prefers-color-scheme: dark) {
:root[data-brand='hansetrans'] {
--brand-soft: rgb(104 176 34 / 16%);
--text-muted: #a4a7a8;
/* Auf dunklen Flächen bleibt das Achteck grün, der Schriftzug wird weiß. */
--logo-word: #f1f1f2;
}
}
* {
box-sizing: border-box;
}
@@ -59,8 +90,8 @@ body {
h1,
h2,
h3 {
/* Überschriften laut Styleguide: Red Hat Display Semibold in Versalien,
Laufweite 4 %, Zeilenabstand 100 %. */
/* Überschriften laut STADTBOTE-Styleguide: Red Hat Display Semibold in
Versalien, Laufweite 4 %, Zeilenabstand 100 %. */
font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif;
font-weight: 600;
text-transform: uppercase;
@@ -68,6 +99,15 @@ h3 {
line-height: 1;
}
/* HANSETRANS setzt Überschriften gemischt geschrieben mit leicht negativer
Laufweite (Red Hat Display Semibold, Laufweite 20). */
:root[data-brand='hansetrans'] h1,
:root[data-brand='hansetrans'] h2,
:root[data-brand='hansetrans'] h3 {
text-transform: none;
letter-spacing: -0.02em;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
@@ -99,11 +139,35 @@ code {
background: var(--brand);
}
.page__header-tools {
display: flex;
align-items: center;
gap: 10px;
}
/* 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 {
appearance: none;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.brand-switch:hover {
border-color: var(--border-strong);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
color: var(--accent);
color: var(--logo-mark);
}
.brand__signet {
@@ -117,7 +181,14 @@ code {
font-weight: 700;
font-size: 24px;
letter-spacing: 0.06em;
color: var(--accent);
color: var(--logo-word);
}
/* Die HANSETRANS-Wortmarke steht kursiv („Brandon Grotesque Bold Italic"
hier mit der Hausschrift angenähert) in HANSETRANS Grau bzw. Weiß. */
:root[data-brand='hansetrans'] .brand__name {
font-style: italic;
letter-spacing: 0.02em;
}
.page__subtitle {
@@ -131,11 +202,6 @@ code {
gap: 16px;
}
.page__footer {
color: var(--text-muted);
font-size: 13px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
@@ -221,6 +287,13 @@ code {
cursor: pointer;
}
/* Auch Links treten als Knopf auf (z. B. „Auftrag öffnen" im Anruf-Popup). */
a.button {
display: inline-flex;
align-items: center;
text-decoration: none;
}
.button:hover:not(:disabled) {
background: var(--brand-soft);
}
@@ -354,71 +427,6 @@ code {
}
}
/* --- 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);
@@ -663,9 +671,23 @@ code {
.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;
+154
View File
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Job, fetchJob } from './jobs'
/**
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java)
* beide Seiten verstehen denselben Webhook.
*/
const SAMPLE = `{
"type": "job",
"id": 21891263,
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
"state": 9,
"ordertime": "2026-08-13T13:35:27",
"orderdate": "2026-08-13",
"modified": "2026-08-13T13:35:27",
"finished": null,
"vehicle": "Transporter XL",
"service": null,
"canceled": false,
"global": false,
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
"phone": "+491602107449" },
"courier": null,
"tours": [
{ "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
"person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
"remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
"zip": "28327", "city": "Bremen", "finished": null },
{ "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
"comp": "Super Micro Computer B.V.", "person": null, "phone": null, "com": null,
"remark": "Abholreferenz: 8801420234\\nAnzahl an Paletten: 8",
"street": "Het Sterrenbeeld 12-16", "zip": "5215", "city": "'s-Hertogenbosch",
"finished": null }
]
}`
describe('Job.isJob', () => {
it('erkennt die Job-Nutzlast am Kennzeichen "type":"job"', () => {
expect(Job.isJob(JSON.parse(SAMPLE))).toBe(true)
expect(Job.isJob({ name: 'SYSGEN GmbH' })).toBe(false)
expect(Job.isJob({ type: 'addresses', addresses: [] })).toBe(false)
expect(Job.isJob(null)).toBe(false)
})
})
describe('Job.fromJson', () => {
it('füllt alle Felder aus der Beispiel-Nutzlast', () => {
const job = Job.fromJson(JSON.parse(SAMPLE))
expect(job.id).toBe(21891263)
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
expect(job.state).toBe(9)
expect(job.ordertime).toBe('2026-08-13T13:35:27')
expect(job.orderdate).toBe('2026-08-13')
expect(job.finished).toBeUndefined()
expect(job.vehicle).toBe('Transporter XL')
expect(job.service).toBeUndefined()
expect(job.canceled).toBe(false)
expect(job.global).toBe(false)
expect(job.customer?.cscId).toBe(100164)
expect(job.customer?.name).toBe('SYSGEN GmbH')
expect(job.customer?.hq).toBe('Bremen')
expect(job.customer?.phone).toBe('+491602107449')
// Noch kein Kurier zugeteilt (in der Nutzlast null).
expect(job.courier).toBeUndefined()
expect(job.tours).toHaveLength(2)
const first = job.tours[0]
expect(first.id).toBe(22396758)
expect(first.sort).toBe(1)
expect(first.mode).toBe('del')
expect(first.person).toBe('Frau Hoffmann')
expect(first.phone).toBe('+49421409660')
expect(first.remark).toContain('Hebebühne benötigt.')
expect(first.street).toBe('Am Hallacker 48')
// Fehlende Rufnummer und leere Felder der zweiten Station bleiben undefined.
expect(job.tours[1].phone).toBeUndefined()
expect(job.tours[1].city).toBe("'s-Hertogenbosch")
})
it('versteht die ältere Form mit "number" statt "phone"', () => {
const job = Job.fromJson({
type: 'job',
id: 21891253,
courier: { cr_id: 14116, sid: 'B1006', name: 'CA Kurier B1006', number: '+4917xxxxxxx' },
tours: [{ id: 22396738, sort: 1, number: '+4942037010xx' }],
})
expect(job.courier?.phone).toBe('+4917xxxxxxx')
expect(job.tours[0].phone).toBe('+4942037010xx')
})
it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => {
const bare = Job.fromJson({ id: 4711 })
expect(bare.id).toBe(4711)
expect(bare.customer).toBeUndefined()
expect(bare.tours).toEqual([])
expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/)
expect(() => Job.fromJson('kein Objekt')).toThrowError(/"id" fehlt/)
})
})
describe('Job.toJson', () => {
it('liefert wieder die Form des Webhooks', () => {
const job = Job.fromJson(JSON.parse(SAMPLE))
const json = job.toJson()
expect(json.type).toBe('job')
expect(json.id).toBe(21891263)
expect(json.canceled).toBe(false)
expect((json.customer as Record<string, unknown>).csc_id).toBe(100164)
expect((json.customer as Record<string, unknown>).phone).toBe('+491602107449')
expect((json.tours as unknown[]).length).toBe(2)
// Einmal hin und zurück ändert nichts mehr.
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/)
})
})
+273
View File
@@ -0,0 +1,273 @@
/**
* Jobs (Kurieraufträge), wie sie das Fremdsystem über den Webhook schickt
* (`POST /api/webhook/jobs` bzw. `"type":"job"` am generischen Webhook) und wie
* sie das Backend in der MongoDB-Sammlung `jobs` ablegt (JobEntry.java).
*
* Die Klasse {@link Job} ist das typisierte Gegenstück im Browser:
* {@link Job.fromJson} übernimmt beliebiges JSON und liefert einen geprüften
* Job unbekannte Felder werden übergangen, fehlende bleiben `undefined`.
* {@link Job.toJson} liefert wieder die Form des Webhooks.
*
* Die Zeitangaben bleiben die Zeichenketten der Nutzlast
* (z. B. `2026-08-13T13:35:27`, teils auch mit Zeitzonenversatz): ISO-8601
* sortiert auch als Text richtig, und die Schreibweise des Fremdsystems geht
* nicht verloren.
*
* Die Rufnummern hießen in einer älteren Form der Nutzlast `number` statt
* `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
}
/** Ganzzahl der Nutzlast; alles andere wird `undefined`. */
function integer(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
/** Wahrheitswert der Nutzlast; alles andere wird `undefined`. */
function bool(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
/** Der beauftragende Kunde. */
export class JobCustomer {
constructor(
/** Kundenkennung des Fremdsystems (`csc_id`). */
readonly cscId?: number,
/** Firmenname. */
readonly name?: string,
/** Niederlassung, z. B. "Bremen". */
readonly hq?: string,
/** Rufnummer. */
readonly phone?: string,
) {}
static fromJson(value: unknown): JobCustomer {
const json = record(value)
return new JobCustomer(
integer(json.csc_id),
text(json.name),
text(json.hq),
text(json.phone) ?? text(json.number),
)
}
toJson(): Record<string, unknown> {
return { csc_id: this.cscId, name: this.name, hq: this.hq, phone: this.phone }
}
}
/** Der ausführende Kurier. */
export class JobCourier {
constructor(
/** Kurierkennung des Fremdsystems (`cr_id`). */
readonly crId?: number,
/** Kurzkennung, z. B. "B1006". */
readonly sid?: string,
/** Anzeigename. */
readonly name?: string,
/** Rufnummer. */
readonly phone?: string,
) {}
static fromJson(value: unknown): JobCourier {
const json = record(value)
return new JobCourier(
integer(json.cr_id),
text(json.sid),
text(json.name),
text(json.phone) ?? text(json.number),
)
}
toJson(): Record<string, unknown> {
return { cr_id: this.crId, sid: this.sid, name: this.name, phone: this.phone }
}
}
/** Eine Station des Jobs. */
export class JobTour {
constructor(
/** Kennung des Fremdsystems. */
readonly id?: number,
/** Reihenfolge innerhalb des Jobs, 1-basiert. */
readonly sort?: number,
/** Zustand der Station. */
readonly state?: number,
/** Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung). */
readonly mode?: string,
/** Firma an der Station. */
readonly comp?: string,
/** Ansprechperson. */
readonly person?: string,
/** Rufnummer an der Station, kann fehlen. */
readonly phone?: string,
/** Straße und Hausnummer. */
readonly street?: string,
/** Postleitzahl. */
readonly zip?: string,
/** Ort. */
readonly city?: string,
/** Bemerkung. */
readonly com?: string,
/** Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …). */
readonly remark?: string,
/** Wann die Station abgeschlossen wurde, sonst leer. */
readonly finished?: string,
) {}
static fromJson(value: unknown): JobTour {
const json = record(value)
return new JobTour(
integer(json.id),
integer(json.sort),
integer(json.state),
text(json.mode),
text(json.comp),
text(json.person),
text(json.phone) ?? text(json.number),
text(json.street),
text(json.zip),
text(json.city),
text(json.com),
text(json.remark),
text(json.finished),
)
}
toJson(): Record<string, unknown> {
return {
id: this.id,
sort: this.sort,
state: this.state,
mode: this.mode,
comp: this.comp,
person: this.person,
phone: this.phone,
street: this.street,
zip: this.zip,
city: this.city,
com: this.com,
remark: this.remark,
finished: this.finished,
}
}
}
/** Ein Job (Kurierauftrag) mit Kunde, Kurier und Stationen. */
export class Job {
constructor(
/** Kennung des Fremdsystems im Backend zugleich Schlüssel der Sammlung. */
readonly id: number,
/** Sprungadresse des Fremdsystems zur Auftragsansicht. */
readonly url?: string,
/** Zustand des Jobs im Fremdsystem. */
readonly state?: number,
/** Auftragszeit, z. B. "2026-08-13T13:35:27". */
readonly ordertime?: string,
/** Auftragsdatum, z. B. "2026-08-13". */
readonly orderdate?: string,
/** Letzte Änderung im Fremdsystem. */
readonly modified?: string,
/** Wann der Job abgeschlossen wurde, sonst leer. */
readonly finished?: string,
/** Fahrzeugart, z. B. "Transporter XL". */
readonly vehicle?: string,
/** Gebuchte Leistung, kann fehlen. */
readonly service?: string,
/** Ist der Job storniert? */
readonly canceled?: boolean,
/** Bundesweite Vermittlung? */
readonly global?: boolean,
/** Der beauftragende Kunde. */
readonly customer?: JobCustomer,
/** Der ausführende Kurier, solange keiner zugeteilt ist `undefined`. */
readonly courier?: JobCourier,
/** Die Stationen des Jobs, in der Reihenfolge der Nutzlast (`sort`). */
readonly tours: JobTour[] = [],
) {}
/** Trägt die Nutzlast das Kennzeichen `"type":"job"`? */
static isJob(value: unknown): boolean {
return record(value).type === 'job'
}
/**
* Füllt die Klasse aus der Webhook-Nutzlast.
*
* @throws wenn die Kennung fehlt ohne sie ist es kein Job
*/
static fromJson(value: unknown): Job {
const json = record(value)
const id = integer(json.id)
if (id === undefined) {
throw new Error('Nutzlast ist kein Job das Feld "id" fehlt.')
}
const tours = Array.isArray(json.tours) ? json.tours.map(JobTour.fromJson) : []
// `null` heißt: noch kein Kurier zugeteilt dann bleibt es undefined.
const hasCustomer = json.customer !== undefined && json.customer !== null
const hasCourier = json.courier !== undefined && json.courier !== null
return new Job(
id,
text(json.url),
integer(json.state),
text(json.ordertime),
text(json.orderdate),
text(json.modified),
text(json.finished),
text(json.vehicle),
text(json.service),
bool(json.canceled),
bool(json.global),
hasCustomer ? JobCustomer.fromJson(json.customer) : undefined,
hasCourier ? JobCourier.fromJson(json.courier) : undefined,
tours,
)
}
/** Die Form des Webhooks, z. B. zum Weiterreichen oder Anzeigen. */
toJson(): Record<string, unknown> {
return {
type: 'job',
id: this.id,
url: this.url,
state: this.state,
ordertime: this.ordertime,
orderdate: this.orderdate,
modified: this.modified,
finished: this.finished,
vehicle: this.vehicle,
service: this.service,
canceled: this.canceled,
global: this.global,
customer: this.customer?.toJson(),
courier: this.courier?.toJson(),
tours: this.tours.map((tour) => tour.toJson()),
}
}
}
+4
View File
@@ -1,8 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { applyBrand, loadBrand } from './brand'
import './index.css'
// Vor dem ersten Rendern, damit die Seite nicht kurz in der falschen Marke aufblitzt.
applyBrand(loadBrand())
const container = document.getElementById('root')
if (!container) {
throw new Error('Root-Element #root nicht gefunden')
+105 -54
View File
@@ -1,13 +1,16 @@
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
import { DEFAULT_WS_URL, fetchClientConfig, clearSavedWsUrl, loadSavedWsUrl, saveWsUrl } from '../config'
import { useSwyxTray } from '../hooks/useSwyxTray'
import { useCallNotifications } from '../hooks/useCallNotifications'
import { useWebhook } from '../hooks/useWebhook'
import { countUnseen } from '../webhook'
import { applyBrand, loadBrand, type Brand } from '../brand'
import { findCallerByNumber, type StoredAddress } from '../addresses'
import StatusBadge from '../components/StatusBadge'
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'
@@ -38,7 +41,9 @@ export default function HomePage() {
const [url, setUrl] = useState(DEFAULT_WS_URL)
const [configNote, setConfigNote] = useState<string | null>(null)
const [tab, setTab] = useState<TabName>('calls')
const [draft, setDraft] = useState('')
// Erscheinungsbild (CI/CD): STADTBOTE oder HANSETRANS, gemerkt über Neustarts.
const [brand, setBrand] = useState<Brand>(loadBrand)
useEffect(() => applyBrand(brand), [brand])
const tray = useSwyxTray()
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
@@ -47,6 +52,38 @@ export default function HomePage() {
const { permission, requestPermission } = useCallNotifications(ringingCall)
// Meldet SwyxIt! einen eingehenden Anruf, geht das Popup auf mit den
// Daten aus der Adress-Ablage, wenn die Rufnummer einem Kunden oder Kurier
// 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(() => {
if (!ringingCall) {
// Anruf vorbei: Der nächste auch von derselben Nummer zählt neu.
lookedUpRef.current = null
return
}
const number = ringingCall.peerNumber?.trim()
if (!number) return
const key = `${ringingCall.line}:${number}`
if (lookedUpRef.current === key) return
lookedUpRef.current = key
let stale = false
findCallerByNumber(number)
.then((match) => {
if (!stale) setCaller(match ?? { number })
})
.catch(() => {
// 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
}
}, [ringingCall])
// Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht
// an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
// anderer Bereich offen ist.
@@ -68,13 +105,25 @@ export default function HomePage() {
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
const urlTouched = useRef(false)
// Adresse des laufenden Verbindungsversuchs; gemerkt wird sie erst, wenn die
// Verbindung tatsächlich zustande kommt.
const pendingUrl = useRef<string | null>(null)
useEffect(() => {
// Mit der zuletzt erfolgreich verbundenen Adresse direkt wieder verbinden
// die Backend-Konfiguration muss dann nicht gefragt werden.
const saved = loadSavedWsUrl()
if (saved) {
urlTouched.current = true
setUrl(saved)
pendingUrl.current = saved
tray.connect(saved)
return
}
const controller = new AbortController()
fetchClientConfig(controller.signal)
.then((backendUrl) => {
if (controller.signal.aborted) return
setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`)
if (!urlTouched.current) setUrl(backendUrl)
})
.catch((e: unknown) => {
@@ -83,45 +132,63 @@ export default function HomePage() {
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) Standardadresse wird verwendet.`)
})
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps -- nur beim Laden der Seite
}, [])
// Erst die zustande gekommene Verbindung zählt: dann die Adresse merken, damit
// sie nach Verbindungsverlust oder Neustart automatisch wieder aufgebaut wird.
useEffect(() => {
if (status === 'open' && pendingUrl.current) saveWsUrl(pendingUrl.current)
}, [status])
function handleConnect(event: FormEvent) {
event.preventDefault()
const target = url.trim()
if (target) tray.connect(target)
if (!target) return
pendingUrl.current = target
tray.connect(target)
}
function handleSendRaw(event: FormEvent) {
event.preventDefault()
const message = draft.trim()
if (message && tray.sendRaw(message)) setDraft('')
// Manuelles Trennen heißt auch: nach einem Neustart nicht wieder verbinden.
function handleDisconnect() {
clearSavedWsUrl()
pendingUrl.current = null
tray.disconnect()
}
return (
<div className="page">
<header className="page__header">
<div className="brand">
<StadtboteSignet className="brand__signet" />
{brand === 'hansetrans' ? (
<HansetransSignet className="brand__signet" />
) : (
<StadtboteSignet className="brand__signet" />
)}
<div>
<h1 className="brand__name">Stadtbote</h1>
<p className="page__subtitle">SwyxWeb · Telefonie über die SwyxTray-App</p>
<h1 className="brand__name">{brand === 'hansetrans' ? 'HANSETRANS' : 'Stadtbote'}</h1>
<p className="page__subtitle">SwyxWeb</p>
</div>
</div>
<StatusBadge status={status} />
<div className="page__header-tools">
{/* Erscheinungsbild (CI/CD) umschalten im Badge-Stil, damit es
neben dem Verbindungsstatus nicht aus der Reihe fällt. */}
<select
className="brand-switch"
aria-label="Erscheinungsbild"
title="Erscheinungsbild"
value={brand}
onChange={(e) => setBrand(e.target.value as Brand)}
>
<option value="stadtbote">STADTBOTE</option>
<option value="hansetrans">HANSETRANS</option>
</select>
<StatusBadge status={status} />
</div>
</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'}>
@@ -165,9 +232,6 @@ export default function HomePage() {
active={tab === 'contacts'}
disabled={!isConnected}
cache={tray.addressCache}
// Unverpackt weitergereicht: der Rückruf ist stabil, damit das
// Panel nicht bei jedem Rendern neu lädt.
onLoad={tray.loadDirectory}
onDial={(number) => void tray.dial(number)}
/>
{error && <p className="note note--error">{error}</p>}
@@ -228,7 +292,7 @@ export default function HomePage() {
<button
type="button"
className="button"
onClick={tray.disconnect}
onClick={handleDisconnect}
disabled={!isBusyConnection}
>
Trennen
@@ -251,26 +315,8 @@ export default function HomePage() {
<section className="card">
<h2>Diagnose</h2>
<MessageLog entries={log} />
<form className="row" onSubmit={handleSendRaw}>
<label className="field">
<span className="field__label">Rohnachricht senden</span>
<input
className="field__input"
type="text"
value={draft}
disabled={!isConnected}
placeholder={'{"id":1,"cmd":"status"}'}
onChange={(e) => setDraft(e.target.value)}
/>
</label>
<div className="row">
<div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={!isConnected || !draft.trim()}
>
Senden
</button>
<button
type="button"
className="button"
@@ -288,17 +334,22 @@ export default function HomePage() {
Log leeren
</button>
</div>
</form>
</div>
</section>
</TabPanel>
</main>
<footer className="page__footer">
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> / <code>contacts</code> ·
Zustand über{' '}
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
</footer>
{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>
)
}