Abgeschlossene Zeilen tragen jetzt ein Badge (Gesendet grün, Verworfen/Erledigt neutral) und die Zeile "Gesendet von Anna · vor 2 Std." aus doneBy/doneAt statt des Alters ab Anlage. Menschen (mensch:<id>) werden über die Mitarbeiterliste aufgelöst, ein Worker wie max über das Rollen-Register als "Max (automatisch)" und nie als "Kollege". Offene Zeilen bleiben unverändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
294 lines
12 KiB
React
294 lines
12 KiB
React
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import Badge from '../ui/Badge'
|
|
import Button from '../ui/Button'
|
|
import EmptyState from '../ui/EmptyState'
|
|
import { copyTextChecked } from '../CopyableCredential'
|
|
import { useEmployees } from '../../hooks/useEmployees'
|
|
import { usePultFeed } from '../../hooks/usePultFeed'
|
|
import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult'
|
|
import ProspectPanel from './ProspectPanel'
|
|
|
|
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.
|
|
// Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet die Zeile das ehrlich,
|
|
// statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
|
|
// 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: copyTextChecked, 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 (
|
|
<li className="wk-pult-row">
|
|
<div className="wk-pult-head">
|
|
<Badge tone="blue">{row.kindLabel}</Badge>
|
|
<strong className="wk-pult-title">{row.title}</strong>
|
|
{row.stale.stale && <Badge tone="orange">{row.stale.label}</Badge>}
|
|
{row.statusLabel && <Badge tone={row.statusTone}>{row.statusLabel}</Badge>}
|
|
</div>
|
|
<div className="wk-pult-meta">
|
|
{row.stale.soon && <span>{row.stale.label}</span>}
|
|
{row.claimedByLabel && <span>übernommen von {row.claimedByLabel}</span>}
|
|
{/* Abgeschlossen: was daraus wurde, wer es tat und wann (das Alter ab Anlage führte dort in die Irre) */}
|
|
{row.doneLine ? <span>{row.doneLine}</span> : row.ageLabel && <span>{row.ageLabel}</span>}
|
|
{info && <span>{info}</span>}
|
|
{row.sourceHref && (
|
|
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer">Anzeige öffnen</a>
|
|
)}
|
|
{prospect && (
|
|
<button type="button" className="link" onClick={() => onDetails(prospect.$id)}>Details</button>
|
|
)}
|
|
</div>
|
|
|
|
{item.originalText && (
|
|
<details className="wk-pult-original">
|
|
<summary>Originalanfrage</summary>
|
|
<p className="wk-pult-original-text">{item.originalText}</p>
|
|
</details>
|
|
)}
|
|
|
|
{row.canSend ? (
|
|
<textarea
|
|
className="textarea wk-pult-draft"
|
|
value={text}
|
|
onChange={(e) => onText(row.id, e.target.value)}
|
|
aria-label="Entwurf der Antwort"
|
|
/>
|
|
) : (
|
|
item.draft && (
|
|
<details className="wk-pult-original">
|
|
<summary>Entwurf</summary>
|
|
<p className="wk-pult-original-text">{item.draft}</p>
|
|
</details>
|
|
)
|
|
)}
|
|
|
|
{(row.canSend || row.canClaim || row.canDone || row.canDiscard) && (
|
|
<div className="row-actions wk-pult-actions">
|
|
{row.canSend && row.kind === 'mail_antwort' && (
|
|
mail
|
|
? <a className="btn btn-primary" href={mail}>E-Mail schreiben</a>
|
|
: <span className="wk-hint">Keine gültige E-Mail-Adresse hinterlegt.</span>
|
|
)}
|
|
{row.canSend && row.kind !== 'mail_antwort' && (
|
|
<Button type="button" variant="primary" onClick={copyOpen} disabled={busy}>
|
|
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
|
|
</Button>
|
|
)}
|
|
{row.canClaim && (
|
|
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}>
|
|
Ich kümmere mich
|
|
</Button>
|
|
)}
|
|
{row.canSend && (
|
|
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)} disabled={busy || text.trim() === ''}>
|
|
Als gesendet markieren
|
|
</Button>
|
|
)}
|
|
{row.canDone && (
|
|
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
|
|
Erledigt
|
|
</Button>
|
|
)}
|
|
{row.canDiscard && (
|
|
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
|
|
{confirming ? 'Wirklich verwerfen?' : 'Verwerfen'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
// Reiter „Freigabe“: die Einträge des Pults, die ein Mensch absendet, übernimmt oder abschließt. Nichts hier versendet selbst etwas:
|
|
// „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die Anzeige, abgeschickt wird dort von Hand.
|
|
// me = { id, name } des Angemeldeten.
|
|
export default function PultList({ api, me }) {
|
|
const [filterId, setFilterId] = useState('offen')
|
|
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
|
|
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
|
|
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
|
|
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
|
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
|
|
const { employees } = useEmployees()
|
|
const nowMs = Date.now()
|
|
|
|
const chooseFilter = (id) => {
|
|
setFilterId(id)
|
|
setFinished('')
|
|
}
|
|
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
|
|
const onFinished = useCallback((id, message) => {
|
|
setFinished(message)
|
|
setDrafts((d) => {
|
|
const { [id]: _gone, ...rest } = d
|
|
return rest
|
|
})
|
|
}, [])
|
|
|
|
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
|
|
|
|
let body
|
|
if (loading) {
|
|
body = <div className="loading">Wird geladen …</div>
|
|
} else if (rows.length === 0 && error) {
|
|
body = (
|
|
<EmptyState
|
|
title="Das Pult ist gerade nicht erreichbar"
|
|
hint={<p>{error}</p>}
|
|
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
|
|
/>
|
|
)
|
|
} else if (rows.length === 0) {
|
|
const empty = EMPTY[filter.id] ?? EMPTY.offen
|
|
body = <EmptyState title={empty.title} hint={<p>{empty.hint}</p>} />
|
|
} else {
|
|
body = (
|
|
<ul className="wk-pult-list">
|
|
{rows.map(({ item, row }) => (
|
|
<PultRow
|
|
key={row.id}
|
|
api={api}
|
|
item={item}
|
|
row={row}
|
|
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
|
|
onText={setText}
|
|
refresh={refresh}
|
|
onFinished={onFinished}
|
|
onDetails={setDetailId}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="wk-pult-filters">
|
|
<div className="seg" role="group" aria-label="Filter">
|
|
{PULT_FILTERS.map((f) => (
|
|
<button
|
|
key={f.id}
|
|
type="button"
|
|
className={f.id === filter.id ? 'on' : undefined}
|
|
aria-pressed={f.id === filter.id}
|
|
onClick={() => chooseFilter(f.id)}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{finished && <div className="msg ok" role="status">{finished}</div>}
|
|
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
|
{body}
|
|
{detailId && <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />}
|
|
</>
|
|
)
|
|
}
|