diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx new file mode 100644 index 0000000..7f563cc --- /dev/null +++ b/src/components/workers/PultList.jsx @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import Badge from '../ui/Badge' +import Button from '../ui/Button' +import EmptyState from '../ui/EmptyState' +import { copyText } from '../CopyableCredential' +import { useEmployees } from '../../hooks/useEmployees' +import { usePultFeed } from '../../hooks/usePultFeed' +import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult' + +const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick +const ACTION_FAILED = 'Das hat nicht geklappt.' + +// Leerzustand je Filter +const EMPTY = { + offen: { title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' }, + veraltet: { title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' }, + erledigt: { title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' }, +} + +// Neuer Tab ohne Rückbezug auf diese Seite +const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer') + +// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was die Zeile anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren) +function copyNote(result, hasUrl) { + if (!hasUrl) { + return result.copied + ? { kind: 'ok', text: 'Kopiert.' } + : { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' } + } + if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' } + if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' } + if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' } + return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' } +} + +// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der +// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und +// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt. +function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails }) { + const [busy, setBusy] = useState(false) + const [confirming, setConfirming] = useState(false) + const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text } + const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick) + const aliveRef = useRef(true) + + useEffect(() => { + aliveRef.current = true + return () => { aliveRef.current = false } + }, []) + + // Die Rückfrage vor dem Verwerfen verfällt nach 4 s + useEffect(() => { + if (!confirming) return undefined + const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS) + return () => window.clearTimeout(timer) + }, [confirming]) + + const begin = () => { + if (busyRef.current) return false + busyRef.current = true + setBusy(true) + setConfirming(false) + setNote(null) + return true + } + const end = () => { + busyRef.current = false + if (aliveRef.current) setBusy(false) + } + + // Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile. + const run = async (call, done, finished) => { + if (!begin()) return + try { + await call() + if (finished) onFinished(row.id, `„${row.title}“ ${done}`) + else if (aliveRef.current) setNote({ kind: 'ok', text: done }) + } catch (err) { + if (aliveRef.current) setNote({ kind: 'err', text: err?.message || ACTION_FAILED }) + } + await refresh() + end() + } + + // Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen. + // Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis. + const copyOpen = async () => { + if (!begin()) return + const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyText, open: openTab }) + let next = copyNote(result, row.sourceHref !== null) + if (!row.claimedByMe) { + try { + await api.pultAction(row.id, 'claim') + } catch (err) { + const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED + next = { kind: 'err', text: `${next.text} ${hint}` } + } + await refresh() + } + if (aliveRef.current) setNote(next) + end() + } + + const discard = () => { + if (!confirming) { + setConfirming(true) + return + } + run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true) + } + + const { prospect } = row + const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : '' + const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ') + // Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf + const mail = row.kind === 'mail_antwort' && prospect && prospect.email + ? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text }) + : '' + + return ( +
  • +
    + {row.kindLabel} + {row.title} + {row.stale.stale && {row.stale.label}} +
    +
    + {row.stale.soon && {row.stale.label}} + {row.claimedByLabel && übernommen von {row.claimedByLabel}} + {row.ageLabel && {row.ageLabel}} + {info && {info}} + {row.sourceHref && ( + Anzeige öffnen + )} + {prospect && ( + + )} +
    + + {item.originalText && ( +
    + Originalanfrage +

    {item.originalText}

    +
    + )} + + {row.canSend ? ( +