Compare commits

..
3 Commits
Author SHA1 Message Date
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
25 changed files with 1371 additions and 230 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 {
@@ -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
* 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 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" die Herkunft
* eines Eintrags steht damit im Eintrag selbst
* @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,
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
@@ -66,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, 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}. */
@@ -76,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;
}
}
@@ -66,6 +66,10 @@ public class ArchiveService {
new Update()
.set("name", entry.name())
.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")
@@ -101,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,9 +72,14 @@ 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) {
}
/**
@@ -67,10 +88,14 @@ public record JobEntry(
* @param crId Kurierkennung des Fremdsystems ({@code cr_id})
* @param sid Kurzkennung, z. B. "B1006"
* @param name Anzeigename
* @param number Rufnummer
* @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());
}
}
@@ -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;
}
}
@@ -54,7 +54,7 @@ class AddressControllerTests {
@Test
void listPassesQueryToArchive() throws Exception {
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"))
.andExpect(status().isOk())
@@ -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. */
@@ -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");
}
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { pickCustomer, 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('pickCustomer', () => {
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' },
]
it('findet den Kunden zur gemeldeten Rufnummer', () => {
const match = pickCustomer(entries, '001602107449')
expect(match?.name).toBe('SYSGEN GmbH')
expect(match?.url).toContain('tapi_wrapper')
})
it('übergeht Telefonbuch-Einträge und Kuriere', () => {
expect(pickCustomer(entries, '5215')).toBeNull()
expect(pickCustomer(entries, '+49171677xxxx')).toBeNull()
})
it('liefert null, wenn keine Rufnummer passt', () => {
expect(pickCustomer(entries, '+49404711')).toBeNull()
})
})
+84 -28
View File
@@ -1,5 +1,89 @@
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 (Rolle `customer`) aus `entries`, dessen Rufnummer passt. */
export function pickCustomer(entries: StoredAddress[], number: string): StoredAddress | null {
return (
entries.find(
(entry) => entry.role === 'customer' && entry.number && sameNumber(entry.number, number),
) ?? null
)
}
/**
* Sucht in der Ablage den Kunden 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 findCustomerByNumber(number: string): Promise<StoredAddress | null> {
const digits = significantDigits(number)
if (!digits) return null
const entries = await fetchAddressEntries(digits.slice(-9))
return pickCustomer(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).
@@ -8,34 +92,6 @@ import type { Contact } from './swyx/protocol'
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
* 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 {
if (contacts.length === 0) return
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.
}
}
@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react'
import type { StoredAddress } from '../addresses'
import { fetchJob } from '../jobs'
interface Props {
/** Der Kunde aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */
customer: StoredAddress
/**
* Nimmt den Anruf an; solange er klingelt gesetzt, danach `undefined`
* der Knopf „Anruf annehmen" verschwindet dann von selbst.
*/
onAnswer?: () => void
onClose: () => void
}
/**
* Popup zu einem eingehenden Anruf, dessen Rufnummer zu einem Kunden in der
* Adress-Ablage gehört: zeigt die Kundendaten, dazu je Job-Kennung des Kunden
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
* Wartedialog (siehe LoadingDialog zur Begründung gegen `<dialog>`).
*/
export default function CustomerCallPopup({ customer, 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):
* 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 {
@@ -216,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);
}
@@ -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 {
background: var(--ok);
border-color: var(--ok);
@@ -658,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')
+70 -13
View File
@@ -4,10 +4,13 @@ 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 { findCustomerByNumber, 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,6 +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')
// 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
@@ -46,6 +52,38 @@ export default function HomePage() {
const { permission, requestPermission } = useCallNotifications(ringingCall)
// Meldet SwyxIt! einen eingehenden Anruf, geht das Popup auf mit den
// Kundendaten, wenn die Rufnummer in der Adress-Ablage einem Kunden gehört,
// sonst nur mit der Rufnummer. Je Anruf nur eine Abfrage der Snapshot
// kommt mehrfach.
const [caller, setCaller] = useState<StoredAddress | null>(null)
const lookedUpRef = useRef<string | null>(null)
useEffect(() => {
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
findCustomerByNumber(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.
@@ -123,27 +161,34 @@ export default function HomePage() {
<div className="page">
<header className="page__header">
<div className="brand">
{brand === 'hansetrans' ? (
<HansetransSignet className="brand__signet" />
) : (
<StadtboteSignet className="brand__signet" />
)}
<div>
<h1 className="brand__name">Stadtbote</h1>
<h1 className="brand__name">{brand === 'hansetrans' ? 'HANSETRANS' : 'Stadtbote'}</h1>
<p className="page__subtitle">SwyxWeb</p>
</div>
</div>
<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'}>
@@ -293,6 +338,18 @@ export default function HomePage() {
</section>
</TabPanel>
</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>
)
}