Kartenlinks für Jobs speichern und im Anruf-Popup öffnen
This commit is contained in:
1 parent
55e5d34e83
commit
7b3c187985
9 files changed
+150
-13
No files matched your search
@@ -18,7 +18,8 @@ interface Props {
|
||||
* Adress-Ablage (Kunde oder Kurier), dazu je Job-Kennung des Eintrags
|
||||
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
|
||||
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
|
||||
* Wartedialog (siehe LoadingDialog zur Begründung gegen `<dialog>`).
|
||||
* Wartedialog. Ist `map_url` gesetzt, öffnet „Karte öffnen" die zugehörige Karte.
|
||||
* Siehe LoadingDialog zur Begründung gegen `<dialog>`.
|
||||
*/
|
||||
export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -27,6 +28,20 @@ export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props
|
||||
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)
|
||||
const [mapUrls, setMapUrls] = useState<Record<number, string>>({})
|
||||
|
||||
// Kartenadressen vorab laden, damit der optionale Knopf schon im Popup
|
||||
// verfügbar ist. Die vorhandene Job-Aktion bleibt auch bei Ladefehlern nutzbar.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setMapUrls({})
|
||||
for (const jobId of customer.jobIds ?? []) {
|
||||
void fetchJob(jobId).then((job) => {
|
||||
if (active && job.mapUrl) setMapUrls((current) => ({ ...current, [jobId]: job.mapUrl! }))
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
return () => { active = false }
|
||||
}, [customer])
|
||||
|
||||
// Fokus in den Dialog holen, damit Escape sofort greift.
|
||||
useEffect(() => {
|
||||
@@ -78,15 +93,23 @@ export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props
|
||||
<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 key={jobId} className="dialog__job">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busyJobId != null}
|
||||
onClick={() => void openJob(jobId)}
|
||||
>
|
||||
{busyJobId === jobId ? `Job ${jobId} …` : `Job ${jobId}`}
|
||||
</button>
|
||||
{mapUrls[jobId] && (
|
||||
<a className="button" href={mapUrls[jobId]} target="_blank" rel="noopener noreferrer"
|
||||
aria-label={`Karte für Job ${jobId} öffnen`}
|
||||
title="Karte in einem neuen Tab öffnen">
|
||||
Karte öffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -871,7 +871,7 @@ a.button {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Die Job-Knöpfe des Anruf-Popups: einer je Kennung, umbrechend. */
|
||||
/* Je Job bleiben Auftrags- und optionaler Kartenknopf zusammen. */
|
||||
.dialog__jobs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -879,6 +879,12 @@ a.button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dialog__job {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -9,6 +9,7 @@ const SAMPLE = `{
|
||||
"type": "job",
|
||||
"id": 21891263,
|
||||
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
|
||||
"map_url": "https://vhs.assecutor.org/?jb=21891263&cr=13385",
|
||||
"state": 9,
|
||||
"ordertime": "2026-08-13T13:35:27",
|
||||
"orderdate": "2026-08-13",
|
||||
@@ -49,6 +50,7 @@ describe('Job.fromJson', () => {
|
||||
|
||||
expect(job.id).toBe(21891263)
|
||||
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
|
||||
expect(job.mapUrl).toBe('https://vhs.assecutor.org/?jb=21891263&cr=13385')
|
||||
expect(job.state).toBe(9)
|
||||
expect(job.ordertime).toBe('2026-08-13T13:35:27')
|
||||
expect(job.orderdate).toBe('2026-08-13')
|
||||
@@ -95,12 +97,18 @@ describe('Job.fromJson', () => {
|
||||
it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => {
|
||||
const bare = Job.fromJson({ id: 4711 })
|
||||
expect(bare.id).toBe(4711)
|
||||
expect(bare.mapUrl).toBeUndefined()
|
||||
expect(bare.customer).toBeUndefined()
|
||||
expect(bare.tours).toEqual([])
|
||||
|
||||
expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/)
|
||||
expect(() => Job.fromJson('kein Objekt')).toThrowError(/"id" fehlt/)
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', ' ', 123, 'keine URL', 'javascript:alert(1)', 'data:text/html,test'])
|
||||
('öffnet keinen Kartenlink für map_url=%s', (map_url) => {
|
||||
expect(Job.fromJson({ id: 21891233, map_url }).mapUrl).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Job.toJson', () => {
|
||||
@@ -110,6 +118,7 @@ describe('Job.toJson', () => {
|
||||
|
||||
expect(json.type).toBe('job')
|
||||
expect(json.id).toBe(21891263)
|
||||
expect(json.map_url).toBe('https://vhs.assecutor.org/?jb=21891263&cr=13385')
|
||||
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')
|
||||
@@ -137,6 +146,7 @@ describe('fetchJob', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/jobs/21891263')
|
||||
expect(job.id).toBe(21891263)
|
||||
expect(job.mapUrl).toBe('https://vhs.assecutor.org/?jb=21891263&cr=13385')
|
||||
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,18 @@ function text(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined
|
||||
}
|
||||
|
||||
/** Externe Kartenlinks dürfen nur normale HTTP(S)-Adressen öffnen. */
|
||||
function mapUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined
|
||||
const target = value.trim()
|
||||
try {
|
||||
const url = new URL(target)
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? target : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Ganzzahl der Nutzlast; alles andere wird `undefined`. */
|
||||
function integer(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
@@ -186,6 +198,8 @@ export class Job {
|
||||
readonly id: number,
|
||||
/** Sprungadresse des Fremdsystems zur Auftragsansicht. */
|
||||
readonly url?: string,
|
||||
/** Optionale Kartenadresse des Jobs (`map_url`). */
|
||||
readonly mapUrl?: string,
|
||||
/** Zustand des Jobs im Fremdsystem. */
|
||||
readonly state?: number,
|
||||
/** Auftragszeit, z. B. "2026-08-13T13:35:27". */
|
||||
@@ -235,6 +249,7 @@ export class Job {
|
||||
return new Job(
|
||||
id,
|
||||
text(json.url),
|
||||
mapUrl(json.map_url),
|
||||
integer(json.state),
|
||||
text(json.ordertime),
|
||||
text(json.orderdate),
|
||||
@@ -256,6 +271,7 @@ export class Job {
|
||||
type: 'job',
|
||||
id: this.id,
|
||||
url: this.url,
|
||||
map_url: this.mapUrl,
|
||||
state: this.state,
|
||||
ordertime: this.ordertime,
|
||||
orderdate: this.orderdate,
|
||||
|
||||
Reference in new issue
Block a user