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>
This commit is contained in:
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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 `<dialog>`).
|
||||
*/
|
||||
export default function CustomerCallPopup({ customer, onClose }: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement>(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 (
|
||||
<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>}
|
||||
|
||||
<div className="dialog__actions">
|
||||
{customer.url && (
|
||||
<a
|
||||
className="button button--primary"
|
||||
href={customer.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
// Der Browser öffnet den neuen Tab; das Popup hat damit seinen
|
||||
// Zweck erfüllt und schließt sich.
|
||||
onClick={onClose}
|
||||
>
|
||||
Auftrag öffnen
|
||||
</a>
|
||||
)}
|
||||
<button ref={closeRef} type="button" className="button" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
})
|
||||
})
|
||||
@@ -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<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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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 && 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() {
|
||||
</section>
|
||||
</TabPanel>
|
||||
</main>
|
||||
|
||||
{caller && <CustomerCallPopup customer={caller} onClose={() => setCaller(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user