From 3a86bc1eee9682d198c65982a67cbf656a9de485 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Fri, 28 Aug 2026 15:40:20 +0200 Subject: [PATCH] =?UTF-8?q?Jobs=20typisiert=20und=20erweitert,=20n=C3=A4ch?= =?UTF-8?q?tliche=20Bereinigung,=20Kunden-Popup=20bei=20Anruf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../swyxweb/BackendApplication.java | 3 + .../swyxweb/storage/AddressEntry.java | 53 +++- .../swyxweb/storage/ArchiveService.java | 25 ++ .../swyxweb/storage/JobCleanupService.java | 58 ++++ .../appcreation/swyxweb/storage/JobEntry.java | 66 +++-- .../swyxweb/AddressControllerTests.java | 2 +- .../swyxweb/AddressEntryTests.java | 28 +- .../swyxweb/JobCleanupServiceTests.java | 32 +++ .../de/appcreation/swyxweb/JobEntryTests.java | 98 ++++--- frontend/src/addresses.test.ts | 63 +++++ frontend/src/addresses.ts | 112 ++++++-- frontend/src/components/CustomerCallPopup.tsx | 66 +++++ frontend/src/index.css | 7 + frontend/src/jobs.test.ts | 121 +++++++++ frontend/src/jobs.ts | 255 ++++++++++++++++++ frontend/src/pages/HomePage.tsx | 34 +++ 16 files changed, 933 insertions(+), 90 deletions(-) create mode 100644 backend/src/main/java/de/appcreation/swyxweb/storage/JobCleanupService.java create mode 100644 backend/src/test/java/de/appcreation/swyxweb/JobCleanupServiceTests.java create mode 100644 frontend/src/addresses.test.ts create mode 100644 frontend/src/components/CustomerCallPopup.tsx create mode 100644 frontend/src/jobs.test.ts create mode 100644 frontend/src/jobs.ts diff --git a/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java index 8d96739..6619727 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java +++ b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java @@ -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 { diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java index 7117f9b..5cf25a6 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java @@ -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. * - *

Alle Quellen liefern dieselben drei Felder, nur unterschiedlich verpackt; - * {@link #manyFrom(JsonNode)} versteht jede dieser Formen: + *

{@link #manyFrom(JsonNode)} versteht jede der gelieferten Formen: *

* * @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 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 longs(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isArray()) return null; + List values = new ArrayList<>(); + for (JsonNode element : value) { + if (element.isIntegralNumber()) values.add(element.longValue()); + } + return values; + } } diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java index e98169f..b4a2c4d 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java @@ -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,27 @@ public class ArchiveService { submit("Jobdaten", () -> mongo.save(job)); } + /** + * 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 calls) { submit("Anrufdaten", () -> calls.forEach(mongo::save)); diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/JobCleanupService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/JobCleanupService.java new file mode 100644 index 0000000..dc7c208 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/JobCleanupService.java @@ -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. + * + *

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")); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java index 0138a2b..18f1771 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java @@ -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. * *

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. + * + *

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 tours) { @@ -56,21 +72,30 @@ public record JobEntry( * @param cscId Kundenkennung des Fremdsystems ({@code csc_id}) * @param name Firmenname * @param hq Niederlassung, z. B. "Bremen" + * @param phone Rufnummer */ @JsonIgnoreProperties(ignoreUnknown = true) - public record Customer(@JsonProperty("csc_id") Integer cscId, String name, String hq) { + public record Customer( + @JsonProperty("csc_id") Integer cscId, + String name, + String hq, + @JsonAlias("number") String phone) { } /** * Der ausführende Kurier. * - * @param crId Kurierkennung des Fremdsystems ({@code cr_id}) - * @param sid Kurzkennung, z. B. "B1006" - * @param name Anzeigename - * @param number Rufnummer + * @param crId Kurierkennung des Fremdsystems ({@code cr_id}) + * @param sid Kurzkennung, z. B. "B1006" + * @param name Anzeigename + * @param phone Rufnummer */ @JsonIgnoreProperties(ignoreUnknown = true) - public record Courier(@JsonProperty("cr_id") Integer crId, String sid, String name, String number) { + public record Courier( + @JsonProperty("cr_id") Integer crId, + String sid, + String name, + @JsonAlias("number") String phone) { } /** @@ -79,29 +104,33 @@ public record JobEntry( * @param id Kennung des Fremdsystems * @param sort Reihenfolge innerhalb des Jobs, 1-basiert * @param state Zustand der Station - * @param mode Art der Station + * @param mode Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung) * @param comp Firma an der Station * @param person Ansprechperson - * @param number Rufnummer an der Station, kann fehlen + * @param phone Rufnummer an der Station, kann fehlen * @param street Straße und Hausnummer * @param zip Postleitzahl * @param city Ort * @param com Bemerkung + * @param remark Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …) * @param finished wann die Station abgeschlossen wurde, sonst leer */ @JsonIgnoreProperties(ignoreUnknown = true) public record Tour( - Long id, + // Ausdrücklich als "id" ablegen – sonst macht Spring Data auch im + // Unterdokument ein "_id" daraus. + @Field("id") Long id, Integer sort, Integer state, String mode, String comp, String person, - String number, + @JsonAlias("number") String phone, String street, String zip, String city, String com, + String remark, String finished) { } @@ -113,7 +142,8 @@ public record JobEntry( /** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */ public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) { JobEntry job = mapper.convertValue(json, JobEntry.class); - return new JobEntry(job.id(), receivedAt, job.state(), job.ordertime(), job.orderdate(), - job.modified(), job.vehicle(), job.customer(), job.courier(), job.tours()); + return new JobEntry(job.id(), receivedAt, job.url(), job.state(), job.ordertime(), + job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(), + job.canceled(), job.global(), job.customer(), job.courier(), job.tours()); } } diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java index cdd5c18..e18f32c 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java @@ -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()) diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java index 42d7d3f..4ed84f0 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java @@ -27,14 +27,34 @@ class AddressEntryTests { @Test void readsSingleWebhookEntry() { List 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 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. */ diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobCleanupServiceTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobCleanupServiceTests.java new file mode 100644 index 0000000..1cf4aad --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/JobCleanupServiceTests.java @@ -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); + } +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java index ef5fbca..d312982 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java @@ -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"); } } diff --git a/frontend/src/addresses.test.ts b/frontend/src/addresses.test.ts new file mode 100644 index 0000000..c4fa0a1 --- /dev/null +++ b/frontend/src/addresses.test.ts @@ -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() + }) +}) diff --git a/frontend/src/addresses.ts b/frontend/src/addresses.ts index 06bedab..5c797a0 100644 --- a/frontend/src/addresses.ts +++ b/frontend/src/addresses.ts @@ -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 { + 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 { + 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 { + 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 { - 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', { diff --git a/frontend/src/components/CustomerCallPopup.tsx b/frontend/src/components/CustomerCallPopup.tsx new file mode 100644 index 0000000..06cf59f --- /dev/null +++ b/frontend/src/components/CustomerCallPopup.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react' +import type { StoredAddress } from '../addresses' + +interface Props { + /** Der Kunde aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */ + customer: StoredAddress + onClose: () => void +} + +/** + * Popup zu einem eingehenden Anruf, dessen Rufnummer zu einem Kunden in der + * Adress-Ablage gehört: zeigt die Kundendaten und – wenn das Fremdsystem eine + * Sprungadresse mitgeliefert hat – einen Knopf, der den Auftrag in einem neuen + * Tab öffnet. Gleiches Overlay wie der Wartedialog (siehe LoadingDialog zur + * Begründung gegen `

`). + */ +export default function CustomerCallPopup({ customer, onClose }: Props) { + const closeRef = useRef(null) + + // Fokus in den Dialog holen, damit Escape sofort greift; der Auftrags-Knopf + // ist ein Link und behält die Bedienung des Browsers (neuer Tab). + useEffect(() => { + closeRef.current?.focus() + }, []) + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + return ( +
+
+

Eingehender Anruf von

+

+ {customer.name ?? customer.number} +

+ {customer.number &&

{customer.number}

} + {customer.description &&

{customer.description}

} + {customer.cscId != null &&

Kundennummer {customer.cscId}

} + +
+ {customer.url && ( + + Auftrag öffnen + + )} + +
+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index fa54a27..ccb4b1d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -287,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); } diff --git a/frontend/src/jobs.test.ts b/frontend/src/jobs.test.ts new file mode 100644 index 0000000..b56e3c2 --- /dev/null +++ b/frontend/src/jobs.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { Job } 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).csc_id).toBe(100164) + expect((json.customer as Record).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) + }) +}) diff --git a/frontend/src/jobs.ts b/frontend/src/jobs.ts new file mode 100644 index 0000000..5f096d8 --- /dev/null +++ b/frontend/src/jobs.ts @@ -0,0 +1,255 @@ +/** + * 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. + */ + +/** 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 { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +/** 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 { + 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 { + 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 { + 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 { + 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()), + } + } +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d3441dd..47a8cec 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -5,7 +5,9 @@ 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' @@ -51,6 +53,36 @@ export default function HomePage() { const { permission, requestPermission } = useCallNotifications(ringingCall) + // Meldet SwyxIt! einen eingehenden Anruf, wird die Rufnummer in der + // Adress-Ablage gesucht; gehört sie einem Kunden, geht das Popup mit dessen + // Daten auf. Je Anruf nur eine Abfrage – der Snapshot kommt mehrfach. + const [caller, setCaller] = useState(null) + const lookedUpRef = useRef(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 && match) setCaller(match) + }) + .catch(() => { + // Ablage nicht erreichbar – dann eben kein Popup; der Anruf selbst + // wird ohnehin über die Anrufkarte gemeldet. + }) + 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. @@ -316,6 +348,8 @@ export default function HomePage() { + + {caller && setCaller(null)} />} ) }