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>
This commit is contained in:
@@ -1,24 +1,34 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
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 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>`).
|
||||
* 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, onClose }: Props) {
|
||||
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; der Auftrags-Knopf
|
||||
// ist ein Link und behält die Bedienung des Browsers (neuer Tab).
|
||||
// Fokus in den Dialog holen, damit Escape sofort greift.
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus()
|
||||
}, [])
|
||||
@@ -31,6 +41,27 @@ export default function CustomerCallPopup({ customer, onClose }: Props) {
|
||||
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">
|
||||
@@ -42,19 +73,37 @@ export default function CustomerCallPopup({ customer, onClose }: Props) {
|
||||
{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">
|
||||
{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}
|
||||
{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}
|
||||
>
|
||||
Auftrag öffnen
|
||||
</a>
|
||||
Anruf annehmen
|
||||
</button>
|
||||
)}
|
||||
<button ref={closeRef} type="button" className="button" onClick={onClose}>
|
||||
Schließen
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
-65
@@ -427,71 +427,6 @@ a.button {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 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);
|
||||
@@ -736,9 +671,23 @@ a.button {
|
||||
.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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Job } from './jobs'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Job, fetchJob } from './jobs'
|
||||
|
||||
/**
|
||||
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java) –
|
||||
@@ -119,3 +119,36 @@ describe('Job.toJson', () => {
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,24 @@
|
||||
* `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
|
||||
|
||||
@@ -11,7 +11,6 @@ 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'
|
||||
@@ -53,9 +52,10 @@ 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.
|
||||
// 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(() => {
|
||||
@@ -72,11 +72,12 @@ export default function HomePage() {
|
||||
let stale = false
|
||||
findCustomerByNumber(number)
|
||||
.then((match) => {
|
||||
if (!stale && match) setCaller(match)
|
||||
if (!stale) setCaller(match ?? { number })
|
||||
})
|
||||
.catch(() => {
|
||||
// Ablage nicht erreichbar – dann eben kein Popup; der Anruf selbst
|
||||
// wird ohnehin über die Anrufkarte gemeldet.
|
||||
// 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
|
||||
@@ -188,17 +189,6 @@ export default function HomePage() {
|
||||
</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'}>
|
||||
@@ -349,7 +339,17 @@ export default function HomePage() {
|
||||
</TabPanel>
|
||||
</main>
|
||||
|
||||
{caller && <CustomerCallPopup customer={caller} onClose={() => setCaller(null)} />}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user