Compare commits

...
6 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
30 changed files with 1416 additions and 371 deletions
+1
View File
@@ -16,6 +16,7 @@ frontend/.env.local
# sonst greift die Ausnahme für die Datei darin nicht. # sonst greift die Ausnahme für die Datei darin nicht.
.vscode/* .vscode/*
!.vscode/launch.json !.vscode/launch.json
!.vscode/tasks.json
*.iml *.iml
.DS_Store .DS_Store
*.log *.log
+6 -2
View File
@@ -9,7 +9,10 @@
"mainClass": "de.appcreation.swyxweb.BackendApplication", "mainClass": "de.appcreation.swyxweb.BackendApplication",
"projectName": "backend", "projectName": "backend",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
"console": "internalConsole" "console": "internalConsole",
// Ein noch laufendes Backend würde Port 8080 belegen und weiter den
// alten Stand ausliefern vor dem Start wird es deshalb beendet.
"preLaunchTask": "Backend-Port 8080 freimachen"
}, },
{ {
// Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock" // Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
@@ -28,7 +31,8 @@
"--spring.profiles.active=mock", "--spring.profiles.active=mock",
"--app.websocket.host=localhost", "--app.websocket.host=localhost",
"--app.websocket.port=8080" "--app.websocket.port=8080"
] ],
"preLaunchTask": "Backend-Port 8080 freimachen"
}, },
{ {
"type": "node-terminal", "type": "node-terminal",
+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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
// Zeitpläne wie die nächtliche Job-Bereinigung (JobCleanupService).
@EnableScheduling
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class }) @EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
public class BackendApplication { public class BackendApplication {
@@ -19,29 +19,39 @@ import org.springframework.data.mongodb.core.mapping.Document;
* Einträge ohne Rufnummer werden deshalb übergangen ohne sie ließe sich die * Einträge ohne Rufnummer werden deshalb übergangen ohne sie ließe sich die
* Dublette nicht erkennen. * Dublette nicht erkennen.
* *
* <p>Alle Quellen liefern dieselben drei Felder, nur unterschiedlich verpackt; * <p>{@link #manyFrom(JsonNode)} versteht jede der gelieferten Formen:
* {@link #manyFrom(JsonNode)} versteht jede dieser Formen:
* <ul> * <ul>
* <li>Webhook, ein Eintrag je Aufruf: * <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>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> * {@code {"addresses":[{"name":…,"number":…,"description":…}, …],"type":"addresses"}}</li>
* </ul> * </ul>
* *
* @param id Kennung des Dokuments, vergibt die MongoDB * @param id Kennung des Dokuments, vergibt die MongoDB
* @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette * @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)" * @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164" * @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen"
* oder "Kurier | NL Hamburg | PKW | cr 2024" die Herkunft * oder "Kurier | NL Hamburg | PKW"
* eines Eintrags steht damit im Eintrag selbst * @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") @Document("addresses")
public record AddressEntry( public record AddressEntry(
@Id String id, @Id String id,
String number, String number,
String name, 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 * Liest aus beliebigem JSON alle Adressdaten-Einträge heraus (siehe die
@@ -66,7 +76,15 @@ public record AddressEntry(
if (!node.isObject()) return; if (!node.isObject()) return;
String number = text(node, "number"); String number = text(node, "number");
if (number == null) return; if (number == null) return;
entries.add(new AddressEntry(null, number, 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}. */ /** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
@@ -76,4 +94,21 @@ public record AddressEntry(
String text = value.stringValue(""); String text = value.stringValue("");
return text.isBlank() ? null : text; 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;
}
} }
@@ -66,6 +66,10 @@ public class ArchiveService {
new Update() new Update()
.set("name", entry.name()) .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 // Altbestand angleichen: Diese Felder wurden früher
// mitgeschrieben und sollen aus der Sammlung verschwinden. // mitgeschrieben und sollen aus der Sammlung verschwinden.
.unset("receivedAt") .unset("receivedAt")
@@ -101,6 +105,37 @@ public class ArchiveService {
submit("Jobdaten", () -> mongo.save(job)); submit("Jobdaten", () -> mongo.save(job));
} }
/**
* Der Job zur Kennung des Fremdsystems gelesen direkt aus der Sammlung
* {@code jobs}, wie {@link #addresses} ohne Zwischenstand im Backend.
*
* @return der Job, oder {@code null} wenn die Kennung nicht abgelegt ist
*/
public JobEntry job(long id) {
return mongo.findById(id, JobEntry.class);
}
/**
* Löscht alle Jobs, deren {@code ordertime} vor der Grenze liegt (Vergleich
* 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. */ /** Legt Anruf-Ereignisse in der Sammlung {@code calls} ab. */
public void saveCalls(List<StoredCall> calls) { public void saveCalls(List<StoredCall> calls) {
submit("Anrufdaten", () -> calls.forEach(mongo::save)); 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.time.Instant;
import java.util.List; import java.util.List;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty; 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.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; 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 * 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. * {@code modified}) ersetzt also seinen alten Stand.
* *
* <p>Die Zeitangaben bleiben die Zeichenketten der Nutzlast * <p>Die Zeitangaben bleiben die Zeichenketten der Nutzlast
* (z. B. {@code 2026-07-31T16:30:00+02:00}): ISO-8601 sortiert auch als Text * (z. B. {@code 2026-08-13T13:35:27}, teils auch mit Zeitzonenversatz):
* richtig, und der Zeitzonenversatz des Fremdsystems geht nicht verloren. * 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 id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs * @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 state Zustand des Jobs im Fremdsystem
* @param ordertime Auftragszeit, z. B. "2026-07-31T16:30:00+02:00" * @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
* @param orderdate Auftragsdatum, z. B. "2026-07-31" * @param orderdate Auftragsdatum, z. B. "2026-08-13"
* @param modified letzte Änderung im Fremdsystem * @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 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 * @param tours die Stationen des Jobs, per {@code sort} geordnet
*/ */
@Document("jobs") @Document("jobs")
@@ -41,11 +52,16 @@ import org.springframework.data.mongodb.core.mapping.Document;
public record JobEntry( public record JobEntry(
@Id long id, @Id long id,
Instant receivedAt, Instant receivedAt,
String url,
Integer state, Integer state,
String ordertime, String ordertime,
String orderdate, String orderdate,
String modified, String modified,
String finished,
String vehicle, String vehicle,
String service,
Boolean canceled,
Boolean global,
Customer customer, Customer customer,
Courier courier, Courier courier,
List<Tour> tours) { List<Tour> tours) {
@@ -56,21 +72,30 @@ public record JobEntry(
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id}) * @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param name Firmenname * @param name Firmenname
* @param hq Niederlassung, z. B. "Bremen" * @param hq Niederlassung, z. B. "Bremen"
* @param phone Rufnummer
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @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. * Der ausführende Kurier.
* *
* @param crId Kurierkennung des Fremdsystems ({@code cr_id}) * @param crId Kurierkennung des Fremdsystems ({@code cr_id})
* @param sid Kurzkennung, z. B. "B1006" * @param sid Kurzkennung, z. B. "B1006"
* @param name Anzeigename * @param name Anzeigename
* @param number Rufnummer * @param phone Rufnummer
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @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 id Kennung des Fremdsystems
* @param sort Reihenfolge innerhalb des Jobs, 1-basiert * @param sort Reihenfolge innerhalb des Jobs, 1-basiert
* @param state Zustand der Station * @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 comp Firma an der Station
* @param person Ansprechperson * @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 street Straße und Hausnummer
* @param zip Postleitzahl * @param zip Postleitzahl
* @param city Ort * @param city Ort
* @param com Bemerkung * @param com Bemerkung
* @param remark Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …)
* @param finished wann die Station abgeschlossen wurde, sonst leer * @param finished wann die Station abgeschlossen wurde, sonst leer
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record Tour( 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 sort,
Integer state, Integer state,
String mode, String mode,
String comp, String comp,
String person, String person,
String number, @JsonAlias("number") String phone,
String street, String street,
String zip, String zip,
String city, String city,
String com, String com,
String remark,
String finished) { String finished) {
} }
@@ -113,7 +142,8 @@ public record JobEntry(
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */ /** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) { public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
JobEntry job = mapper.convertValue(json, JobEntry.class); JobEntry job = mapper.convertValue(json, JobEntry.class);
return new JobEntry(job.id(), receivedAt, job.state(), job.ordertime(), job.orderdate(), return new JobEntry(job.id(), receivedAt, job.url(), job.state(), job.ordertime(),
job.modified(), job.vehicle(), job.customer(), job.courier(), job.tours()); job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
} }
} }
@@ -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;
}
}
@@ -86,7 +86,7 @@ public class WebhookService {
@PreDestroy @PreDestroy
void shutdown() { void shutdown() {
heartbeat.shutdownNow(); heartbeat.shutdownNow();
subscribers.forEach(SseEmitter::complete); subscribers.forEach(emitter -> emitter.complete());
subscribers.clear(); subscribers.clear();
} }
@@ -359,7 +359,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
String needle = wanted.toLowerCase(); String needle = wanted.toLowerCase();
List<MockContact> hits = MOCK_CONTACTS.stream() List<MockContact> hits = MOCK_CONTACTS.stream()
.filter(contact -> contact.matches(needle)) .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) .limit(CONTACT_RESULT_LIMIT)
.toList(); .toList();
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted); log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
@@ -54,7 +54,7 @@ class AddressControllerTests {
@Test @Test
void listPassesQueryToArchive() throws Exception { void listPassesQueryToArchive() throws Exception {
when(archive.addresses("muster")) when(archive.addresses("muster"))
.thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null))); .thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null, null, null, null, null)));
mvc.perform(get("/api/addresses").param("q", "muster")) mvc.perform(get("/api/addresses").param("q", "muster"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -27,14 +27,34 @@ class AddressEntryTests {
@Test @Test
void readsSingleWebhookEntry() { void readsSingleWebhookEntry() {
List<AddressEntry> entries = parse(""" List<AddressEntry> entries = parse("""
{"name":"SYSGEN GmbH","number":"+49421409660", {"name":"SYSGEN GmbH","number":"+491602107449",
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"}"""); "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); assertThat(entries).hasSize(1);
AddressEntry entry = entries.getFirst(); AddressEntry entry = entries.getFirst();
assertThat(entry.name()).isEqualTo("SYSGEN GmbH"); assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
assertThat(entry.number()).isEqualTo("+49421409660"); assertThat(entry.number()).isEqualTo("+491602107449");
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"); 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. */ /** Kuriere und Jobs kommen analog auch als Liste. */
@@ -58,7 +78,7 @@ class AddressEntryTests {
"type":"addresses"}"""); "type":"addresses"}""");
assertThat(entries).hasSize(2); 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"} * {@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 { class JobEntryTests {
@@ -22,21 +24,30 @@ class JobEntryTests {
private static final String SAMPLE = """ private static final String SAMPLE = """
{ {
"type": "job", "type": "job",
"id": 21891253, "id": 21891263,
"state": 2, "url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
"ordertime": "2026-07-31T16:30:00+02:00", "state": 9,
"orderdate": "2026-07-31", "ordertime": "2026-08-13T13:35:27",
"modified": "2026-07-30T18:14:02+02:00", "orderdate": "2026-08-13",
"vehicle": "Transporter", "modified": "2026-08-13T13:35:27",
"customer": { "csc_id": 868374, "name": "B+M isol nord GmbH", "hq": "Bremen" }, "finished": null,
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006", "number": "+4917xxxxxxx" }, "vehicle": "Transporter XL",
"service": null,
"canceled": false,
"global": false,
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
"phone": "+491602107449" },
"courier": null,
"tours": [ "tours": [
{ "id": 22396738, "sort": 1, "state": 1, "mode": "", "comp": "B+M isol nord GmbH", { "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
"person": "", "number": "+4942037010xx", "street": "Löwenhof 2", "zip": "28844", "person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
"city": "Weyhe-Dreye", "com": "", "finished": "2026-07-30T18:13:11+02:00" }, "remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
{ "id": 22396739, "sort": 2, "state": 1, "mode": "", "comp": "Reichspräsident Ebert Kaserne", "zip": "28327", "city": "Bremen", "finished": null },
"person": "", "number": null, "street": "Osdorfer Landstr. 365", "zip": "22589", { "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
"city": "Hamburg", "com": "", "finished": "2026-07-30T18:13:51+02:00" } "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 @Test
void fillsAllFieldsFromSample() { 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); JsonNode json = mapper.readTree(SAMPLE);
JobEntry job = JobEntry.from(json, mapper, receivedAt); JobEntry job = JobEntry.from(json, mapper, receivedAt);
assertThat(job.id()).isEqualTo(21891253L); assertThat(job.id()).isEqualTo(21891263L);
assertThat(job.receivedAt()).isEqualTo(receivedAt); assertThat(job.receivedAt()).isEqualTo(receivedAt);
assertThat(job.state()).isEqualTo(2); assertThat(job.url()).isEqualTo("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263");
assertThat(job.ordertime()).isEqualTo("2026-07-31T16:30:00+02:00"); assertThat(job.state()).isEqualTo(9);
assertThat(job.orderdate()).isEqualTo("2026-07-31"); assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
assertThat(job.vehicle()).isEqualTo("Transporter"); 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().cscId()).isEqualTo(100164);
assertThat(job.customer().name()).isEqualTo("B+M isol nord GmbH"); assertThat(job.customer().name()).isEqualTo("SYSGEN GmbH");
assertThat(job.customer().hq()).isEqualTo("Bremen"); assertThat(job.customer().hq()).isEqualTo("Bremen");
assertThat(job.customer().phone()).isEqualTo("+491602107449");
assertThat(job.courier().crId()).isEqualTo(14116); // Noch kein Kurier zugeteilt.
assertThat(job.courier().sid()).isEqualTo("B1006"); assertThat(job.courier()).isNull();
assertThat(job.courier().number()).isEqualTo("+4917xxxxxxx");
assertThat(job.tours()).hasSize(2); assertThat(job.tours()).hasSize(2);
JobEntry.Tour first = job.tours().getFirst(); JobEntry.Tour first = job.tours().getFirst();
assertThat(first.id()).isEqualTo(22396738L); assertThat(first.id()).isEqualTo(22396758L);
assertThat(first.sort()).isEqualTo(1); assertThat(first.sort()).isEqualTo(1);
assertThat(first.street()).isEqualTo("Löwenhof 2"); assertThat(first.mode()).isEqualTo("del");
assertThat(first.finished()).isEqualTo("2026-07-30T18:13:11+02:00"); 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. // 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");
} }
} }
@@ -10,7 +10,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.appcreation.swyxweb.storage.ArchiveService; import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService; import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@@ -112,7 +111,7 @@ class WebhookControllerTests {
.andExpect(status().isOk()); .andExpect(status().isOk());
assertThat(service.history()) assertThat(service.history())
.extracting(WebhookEvent::channel) .extracting(event -> event.channel())
.containsExactly("kunden", "kuriere", "jobs"); .containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job. // Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
+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 -28
View File
@@ -1,5 +1,93 @@
import type { Contact } from './swyx/protocol' 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 * Meldet den Adress-Cache der SwyxTray-App an das Backend, das die Einträge in
* der MongoDB ablegt (Aktualisierung statt Verdopplung, siehe ArchiveService). * der MongoDB ablegt (Aktualisierung statt Verdopplung, siehe ArchiveService).
@@ -8,34 +96,6 @@ import type { Contact } from './swyx/protocol'
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den * diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter. * Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter.
*/ */
/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
interface StoredAddress {
number?: string | null
name?: string | null
description?: 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 fetchAddresses(query = ''): Promise<Contact[]> {
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}).`)
}
const entries = (await response.json()) as StoredAddress[]
return entries.map((entry) => ({
name: entry.name ?? undefined,
number: entry.number ?? undefined,
description: entry.description ?? undefined,
}))
}
export function reportAddresses(contacts: Contact[]): void { export function reportAddresses(contacts: Contact[]): void {
if (contacts.length === 0) return if (contacts.length === 0) return
void fetch('/api/addresses', { void fetch('/api/addresses', {
+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> </div>
</form> </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>} {hint && <p className="note note--error">{hint}</p>}
{outcome && <p className="note note--success">{outcome}</p>} {outcome && <p className="note note--success">{outcome}</p>}
</> </>
+16 -103
View File
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { describeContact, type Contact } from '../swyx/protocol' import { describeContact, type Contact } from '../swyx/protocol'
import type { Directory, DirectoryProgress, SweepOptions } from '../swyx/directory'
import { fetchAddresses } from '../addresses' import { fetchAddresses } from '../addresses'
import LoadingDialog from './LoadingDialog'
interface Props { interface Props {
/** Panel ist sichtbar erst dann wird abgefragt. */ /** Panel ist sichtbar erst dann wird abgefragt. */
active: boolean active: boolean
/** Keine Verbindung zur SwyxTray-App Anrufen und Telefonbuch-Abgleich entfallen. */ /** Keine Verbindung zur SwyxTray-App Anrufen entfällt. */
disabled: boolean disabled: boolean
/** /**
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht * Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
@@ -15,7 +13,6 @@ interface Props {
* hat ihn gerade hineingeschrieben. * hat ihn gerade hineingeschrieben.
*/ */
cache: Contact[] | null cache: Contact[] | null
onLoad: (options: SweepOptions) => Promise<Directory | null>
onDial: (number: string) => void onDial: (number: string) => void
} }
@@ -36,30 +33,22 @@ const SEARCH_DEBOUNCE_MS = 300
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur * Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
* SwyxTray-App. * SwyxTray-App.
* *
* Das Telefonbuch der App fließt auf zwei Wegen in die Ablage: * Das Telefonbuch der App fließt über den **Cache-Push** beim Verbinden
* * (Protokoll 8) in die Ablage die Startseite meldet ihn ans Backend; das
* 1. Der **Cache-Push** beim Verbinden (Protokoll 8) die Startseite meldet * Panel fragt die Ablage danach neu ab.
* ihn ans Backend; das Panel fragt die Ablage danach neu ab.
* 2. „Neu laden" bei bestehender Verbindung: erst das Telefonbuch der App
* holen (Cache oder rund 110 Einzelabfragen), das Backend legt es ab,
* dann die Ablage abfragen.
*/ */
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) { export default function ContactsPanel({ active, disabled, cache, onDial }: Props) {
// Das jeweils letzte Abfrageergebnis kein Bestand, nur die Anzeige. // Das jeweils letzte Abfrageergebnis kein Bestand, nur die Anzeige.
const [results, setResults] = useState<Contact[] | null>(null) const [results, setResults] = useState<Contact[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
// Warnung aus dem letzten Telefonbuch-Abgleich: dort fehlen evtl. Einträge.
const [incomplete, setIncomplete] = useState(false)
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer. // Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const abortRef = useRef<AbortController | null>(null)
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht // Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
// mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge. // mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge.
const queryIdRef = useRef(0) const queryIdRef = useRef(0)
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts // Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
// (Cache-Push, „Neu laden") ohne den Effekt neu anzustoßen. // (Cache-Push) ohne den Effekt neu anzustoßen.
const filterRef = useRef(filter) const filterRef = useRef(filter)
filterRef.current = filter filterRef.current = filter
@@ -89,42 +78,6 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, filter, search]) }, [active, filter, search])
/**
* „Neu laden": Bei bestehender Verbindung zuerst das Telefonbuch der App
* holen das Backend schreibt es dabei in die Ablage , danach in jedem
* Fall die Ablage frisch abfragen.
*/
const reload = useCallback(async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
try {
if (!disabled) {
setProgress({ done: 0, total: 0, found: 0 })
const result = await onLoad({
signal: controller.signal,
onProgress: (next) => {
if (abortRef.current === controller) setProgress(next)
},
})
if (abortRef.current !== controller) return
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
setIncomplete(result !== null && !result.complete)
// Das Backend schreibt nebenbei; kurz warten, damit die Abfrage den
// frischen Stand schon sieht.
await new Promise((resolve) => setTimeout(resolve, ARCHIVE_WRITE_DELAY_MS))
if (abortRef.current !== controller) return
}
await search()
} finally {
if (abortRef.current === controller) {
abortRef.current = null
setProgress(null)
}
}
}, [disabled, onLoad, search])
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet; // Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
// nach einer kurzen Schreibfrist die Ablage neu abfragen. // nach einer kurzen Schreibfrist die Ablage neu abfragen.
useEffect(() => { useEffect(() => {
@@ -133,25 +86,9 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [cache, search]) }, [cache, search])
// Ein laufender Telefonbuch-Abgleich soll nicht weiterfragen, wenn die
// Seite verschwindet.
useEffect(() => () => abortRef.current?.abort(), [])
const loading = progress !== null
return ( return (
<> <>
<div className="card__header"> <h2>Adressdaten</h2>
<h2>Adressdaten</h2>
<button
type="button"
className="button button--ghost"
onClick={() => void reload()}
disabled={loading}
>
{loading ? 'Lädt …' : 'Neu laden'}
</button>
</div>
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`) {/* 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* und würde als direktes Kind der Karte einer Spalte in die *Höhe*
@@ -180,22 +117,15 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
<> <>
<p className="note"> <p className="note">
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`} {filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
{' · direkt aus der Adress-Ablage des Backends'}
</p> </p>
{incomplete && (
<p className="note note--error">
Der letzte Telefonbuch-Abgleich war unvollständig; es können Einträge fehlen.
Neu laden" versucht es erneut.
</p>
)}
{results.length === 0 ? ( {results.length === 0 ? (
<p className="note"> !filter.trim() && (
{filter.trim() <p className="note">
? `Kein Eintrag zu „${filter.trim()}".` Die Ablage ist noch leer sie füllt sich über die Webhooks und über das
: 'Die Ablage ist noch leer sie füllt sich über die Webhooks und über das Telefonbuch der SwyxTray-App.'} Telefonbuch der SwyxTray-App.
</p> </p>
)
) : ( ) : (
<ul className="contacts"> <ul className="contacts">
{results.map((contact, index) => ( {results.map((contact, index) => (
@@ -212,12 +142,13 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
)} )}
</span> </span>
{contact.number && <span className="contacts__number">{contact.number}</span>} {contact.number && <span className="contacts__number">{contact.number}</span>}
{contact.number && ( {/* Ohne Verbindung zur Tray-App gibt es nichts zu wählen
der Knopf erscheint dann gar nicht erst. */}
{contact.number && !disabled && (
<button <button
type="button" type="button"
className="button button--small" className="button button--small"
onClick={() => onDial(contact.number!)} onClick={() => onDial(contact.number!)}
disabled={disabled}
> >
Anrufen Anrufen
</button> </button>
@@ -228,24 +159,6 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
)} )}
</> </>
)} )}
<p className="note">
Jede Suche fragt die Adress-Ablage des Backends (MongoDB) direkt ab die per Webhook
gemeldeten Kunden und Kuriere sowie das Telefonbuch des Swyx-Clients; im Browser wird
kein Bestand vorgehalten. Das Telefonbuch fließt bei bestehender Verbindung zur
SwyxTray-App in die Ablage ein beim Verbinden von selbst, bei „Neu laden" auf
Anforderung.
</p>
{loading && (
<LoadingDialog
title="Telefonbuch wird abgeglichen"
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>
)
}
+102 -75
View File
@@ -1,10 +1,12 @@
/* /*
* Farb- und Schriftvorgaben aus dem STADTBOTE-Styleguide (HANSETRANS Brandbook): * Farb- und Schriftvorgaben aus dem HANSETRANS Brandbook. Vorgabe ist das
* Primär Rot #CA0D38 (Pantone 185 C) und Grau #7C7F7E (Pantone 424 C), * STADTBOTE-Erscheinungsbild: Primär Rot #CA0D38 (Pantone 185 C) und Grau
* Off-Black #222222 für Fließtext, Grauabstufungen als Flächen. * #7C7F7E (Pantone 424 C), Off-Black #222222 für Fließtext, Grauabstufungen
* Schrift: Red Hat Display (Überschriften, Versalien, Laufweite 4 %) und * als Flächen. Schrift: Red Hat Display (Überschriften, Versalien, Laufweite
* Red Hat Text (Fließtext). Buttons: primär vollflächig, sekundär mit Rahmen, * 4 %) und Red Hat Text (Fließtext). Buttons: primär vollflächig, sekundär mit
* Kästen und Linien mit leichter Abrundung. * Rahmen, Kästen und Linien mit leichter Abrundung.
* Das HANSETRANS-Erscheinungsbild wird weiter unten über data-brand
* am Wurzelelement zugeschaltet.
*/ */
:root { :root {
--bg: #ffffff; --bg: #ffffff;
@@ -20,6 +22,9 @@
--brand-strong: #a30a2d; --brand-strong: #a30a2d;
--brand-soft: rgb(202 13 56 / 8%); --brand-soft: rgb(202 13 56 / 8%);
--accent: #ca0d38; --accent: #ca0d38;
/* Kopfzeile: Bild- und Wortmarke; bei STADTBOTE beide im Markenrot. */
--logo-mark: #ca0d38;
--logo-word: #ca0d38;
--ok: #2f9e6e; --ok: #2f9e6e;
--warn: #b98a1c; --warn: #b98a1c;
--err: #d92b1f; --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; box-sizing: border-box;
} }
@@ -59,8 +90,8 @@ body {
h1, h1,
h2, h2,
h3 { h3 {
/* Überschriften laut Styleguide: Red Hat Display Semibold in Versalien, /* Überschriften laut STADTBOTE-Styleguide: Red Hat Display Semibold in
Laufweite 4 %, Zeilenabstand 100 %. */ Versalien, Laufweite 4 %, Zeilenabstand 100 %. */
font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif; font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -68,6 +99,15 @@ h3 {
line-height: 1; 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 { code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em; font-size: 0.9em;
@@ -99,11 +139,35 @@ code {
background: var(--brand); 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 { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
color: var(--accent); color: var(--logo-mark);
} }
.brand__signet { .brand__signet {
@@ -117,7 +181,14 @@ code {
font-weight: 700; font-weight: 700;
font-size: 24px; font-size: 24px;
letter-spacing: 0.06em; 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 { .page__subtitle {
@@ -216,6 +287,13 @@ code {
cursor: pointer; 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) { .button:hover:not(:disabled) {
background: var(--brand-soft); background: var(--brand-soft);
} }
@@ -349,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 { .button--accept {
background: var(--ok); background: var(--ok);
border-color: var(--ok); border-color: var(--ok);
@@ -658,9 +671,23 @@ code {
.dialog__actions { .dialog__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px;
margin-top: 20px; margin-top: 20px;
} }
/* Schiebt einen Knopf an den linken Rand der Aktionszeile („Anruf annehmen"). */
.dialog__actions-start {
margin-right: auto;
}
/* Die Job-Knöpfe des Anruf-Popups: einer je Kennung, umbrechend. */
.dialog__jobs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.progress { .progress {
height: 8px; height: 8px;
border-radius: 999px; border-radius: 999px;
+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 { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import { applyBrand, loadBrand } from './brand'
import './index.css' import './index.css'
// Vor dem ersten Rendern, damit die Seite nicht kurz in der falschen Marke aufblitzt.
applyBrand(loadBrand())
const container = document.getElementById('root') const container = document.getElementById('root')
if (!container) { if (!container) {
throw new Error('Root-Element #root nicht gefunden') throw new Error('Root-Element #root nicht gefunden')
+74 -44
View File
@@ -4,10 +4,13 @@ import { useSwyxTray } from '../hooks/useSwyxTray'
import { useCallNotifications } from '../hooks/useCallNotifications' import { useCallNotifications } from '../hooks/useCallNotifications'
import { useWebhook } from '../hooks/useWebhook' import { useWebhook } from '../hooks/useWebhook'
import { countUnseen } from '../webhook' import { countUnseen } from '../webhook'
import { applyBrand, loadBrand, type Brand } from '../brand'
import { findCallerByNumber, type StoredAddress } from '../addresses'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import CustomerCallPopup from '../components/CustomerCallPopup'
import StadtboteSignet from '../components/StadtboteSignet' import StadtboteSignet from '../components/StadtboteSignet'
import HansetransSignet from '../components/HansetransSignet'
import MessageLog from '../components/MessageLog' import MessageLog from '../components/MessageLog'
import IncomingCallCard from '../components/IncomingCallCard'
import DialPanel from '../components/DialPanel' import DialPanel from '../components/DialPanel'
import BrowserTabsPanel from '../components/BrowserTabsPanel' import BrowserTabsPanel from '../components/BrowserTabsPanel'
import ContactsPanel from '../components/ContactsPanel' import ContactsPanel from '../components/ContactsPanel'
@@ -38,7 +41,9 @@ export default function HomePage() {
const [url, setUrl] = useState(DEFAULT_WS_URL) const [url, setUrl] = useState(DEFAULT_WS_URL)
const [configNote, setConfigNote] = useState<string | null>(null) const [configNote, setConfigNote] = useState<string | null>(null)
const [tab, setTab] = useState<TabName>('calls') 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 tray = useSwyxTray()
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
@@ -47,6 +52,38 @@ export default function HomePage() {
const { permission, requestPermission } = useCallNotifications(ringingCall) 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 // 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 // an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
// anderer Bereich offen ist. // anderer Bereich offen ist.
@@ -119,37 +156,39 @@ export default function HomePage() {
tray.disconnect() tray.disconnect()
} }
function handleSendRaw(event: FormEvent) {
event.preventDefault()
const message = draft.trim()
if (message && tray.sendRaw(message)) setDraft('')
}
return ( return (
<div className="page"> <div className="page">
<header className="page__header"> <header className="page__header">
<div className="brand"> <div className="brand">
<StadtboteSignet className="brand__signet" /> {brand === 'hansetrans' ? (
<HansetransSignet className="brand__signet" />
) : (
<StadtboteSignet className="brand__signet" />
)}
<div> <div>
<h1 className="brand__name">Stadtbote</h1> <h1 className="brand__name">{brand === 'hansetrans' ? 'HANSETRANS' : 'Stadtbote'}</h1>
<p className="page__subtitle">SwyxWeb</p> <p className="page__subtitle">SwyxWeb</p>
</div> </div>
</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> </header>
<main className="page__main"> <main className="page__main">
{/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon
abhängen, welcher Bereich gerade offen ist. */}
{ringingCall && (
<IncomingCallCard
call={ringingCall}
busy={busy}
onAnswer={(line) => void tray.answer(line)}
onHangup={(line) => void tray.hangup(line)}
/>
)}
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} /> <Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
<TabPanel id="calls" active={tab === 'calls'}> <TabPanel id="calls" active={tab === 'calls'}>
@@ -193,9 +232,6 @@ export default function HomePage() {
active={tab === 'contacts'} active={tab === 'contacts'}
disabled={!isConnected} disabled={!isConnected}
cache={tray.addressCache} 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)} onDial={(number) => void tray.dial(number)}
/> />
{error && <p className="note note--error">{error}</p>} {error && <p className="note note--error">{error}</p>}
@@ -279,26 +315,8 @@ export default function HomePage() {
<section className="card"> <section className="card">
<h2>Diagnose</h2> <h2>Diagnose</h2>
<MessageLog entries={log} /> <MessageLog entries={log} />
<form className="row" onSubmit={handleSendRaw}> <div className="row">
<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__actions"> <div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={!isConnected || !draft.trim()}
>
Senden
</button>
<button <button
type="button" type="button"
className="button" className="button"
@@ -316,10 +334,22 @@ export default function HomePage() {
Log leeren Log leeren
</button> </button>
</div> </div>
</form> </div>
</section> </section>
</TabPanel> </TabPanel>
</main> </main>
{caller && (
<CustomerCallPopup
customer={caller}
// Nur solange es klingelt: Danach fällt der Annehmen-Knopf im Popup
// von selbst weg, die Kundendaten und Job-Knöpfe bleiben stehen.
onAnswer={
ringingCall && !busy ? () => void tray.answer(ringingCall.line) : undefined
}
onClose={() => setCaller(null)}
/>
)}
</div> </div>
) )
} }