Workers P4: Pult – als Mensch antworten, Vorschau freigeben, Antwort des Kunden einfügen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 22:08:48 +00:00
1 parent be2e6786b6
commit 533091c333
7 files changed
+1001 -48

No files matched your search

+151 -21
View File
@@ -5,11 +5,13 @@ 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 {
ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText,
} 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.'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ bzw. „Wirklich freigeben?“ nach dem ersten Klick
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
// Leerzustand je Filter
const EMPTY = {
@@ -42,12 +44,29 @@ function rowName(label, title, joiner = ': ') {
return t === '' ? label : `${label}${joiner}${t}`
}
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (.wk-pult-reply) und „Antwort des Kunden einfügen“ (.wk-pult-paste).
// Textfeld, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und ein Knopf; die Prüfung macht die Zeile beim Absenden.
function TextPanel({ className, label, hint, value, onChange, submitLabel, onSubmit, busy }) {
const count = charCount(value)
return (
<div className={className}>
{hint && <p className="wk-hint">{hint}</p>}
<textarea className="textarea" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} autoFocus />
<div className="wk-pult-panel-foot">
<span className={count > REPLY_TEXT_MAX ? 'wk-pult-count over' : 'wk-pult-count'}>{count} / {REPLY_TEXT_MAX}</span>
<Button type="button" variant="primary" onClick={onSubmit} disabled={busy}>{submitLabel}</Button>
</div>
</div>
)
}
// 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 }) {
function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFinished, onDetails }) {
const [busy, setBusy] = useState(false)
const [confirming, setConfirming] = useState(false)
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve': welche Rückfrage gerade gilt
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
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)
@@ -57,10 +76,10 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
return () => { aliveRef.current = false }
}, [])
// Die Rückfrage vor dem Verwerfen verfällt nach 4 s
// Die Rückfrage vor dem Verwerfen und vor dem Freigeben verfällt nach 4 s
useEffect(() => {
if (!confirming) return undefined
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS)
if (confirming === null) return undefined
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
return () => window.clearTimeout(timer)
}, [confirming])
@@ -68,7 +87,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
if (busyRef.current) return false
busyRef.current = true
setBusy(true)
setConfirming(false)
setConfirming(null)
setNote(null)
return true
}
@@ -78,17 +97,23 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
}
// 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
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
// approve-preview und start-build heißt eine Zeitüberschreitung „Kann gesendet sein – Pult neu laden.“, D14). Ergebnis: ob es geklappt hat.
const run = async (call, done, finished, action) => {
if (!begin()) return false
let ok = false
try {
await call()
if (finished) onFinished(row.id, `„${row.title}“ ${done}`)
else if (aliveRef.current) setNote({ kind: 'ok', text: done })
const result = await call()
const message = typeof done === 'function' ? done(result) : done
if (finished) onFinished(row.id, `„${row.title}“ ${message}`)
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
ok = true
} catch (err) {
if (aliveRef.current) setNote({ kind: 'err', text: err?.message || ACTION_FAILED })
if (aliveRef.current) setNote({ kind: 'err', text: actionErrorText(err, action) })
}
await refresh()
end()
return ok
}
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
@@ -113,13 +138,57 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
}
const discard = () => {
if (!confirming) {
setConfirming(true)
if (confirming !== 'discard') {
setConfirming('discard')
return
}
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
}
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster)
const approve = () => {
if (confirming !== 'approve') {
setConfirming('approve')
return
}
run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
}
// Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“)
const startBuild = () => run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
// Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
// vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text einen Wechsel des Filters überlebt.
const replyValue = fields.reply ?? text
const pasteValue = fields.paste ?? ''
const toggle = (which) => setOpen((current) => (current === which ? null : which))
const sendReply = () => {
const check = validateReplyText(replyValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
}
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
const sendPaste = () => {
const check = validateReplyText(pasteValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(async () => {
const result = await api.pultAction(row.id, 'paste', { text: check.text })
onField(row.id, 'paste', '')
if (aliveRef.current) setOpen(null)
return result
}, pasteDoneText, false, 'paste')
}
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
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(' · ')
@@ -173,7 +242,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
)
)}
{(row.canSend || row.canClaim || row.canDone || row.canDiscard) && (
{hasActions && (
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)}>
{row.canSend && row.kind === 'mail_antwort' && (
mail
@@ -195,19 +264,72 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
Als gesendet markieren
</Button>
)}
{row.canReply && (
<Button type="button" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} disabled={busy}>
Als Mensch antworten
</Button>
)}
{row.canApprovePreview && (
<>
{row.previewHref
? <a className="btn" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}>Vorschau ansehen</a>
: <span className="wk-hint">Keine gültige Vorschau-Adresse hinterlegt.</span>}
<Button type="button" variant="primary" onClick={approve} disabled={busy}>
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
</Button>
</>
)}
{row.canStartBuild && (
<Button type="button" onClick={startBuild} disabled={busy}>
Vorschau bauen
</Button>
)}
{row.canPaste && (
<Button type="button" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} disabled={busy}>
Antwort des Kunden einfügen
</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>
<>
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Button>
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
{row.kind === 'vorschau_freigabe' && <span className="wk-hint">„Verwerfen“ sendet die Vorschau nicht.</span>}
</>
)}
</div>
)}
{open === 'reply' && row.canReply && (
<TextPanel
className="wk-pult-reply"
label={rowName('Text der Antwort', row.title)}
value={replyValue}
onChange={(value) => onField(row.id, 'reply', value)}
submitLabel="Jetzt senden"
onSubmit={sendReply}
busy={busy}
/>
)}
{open === 'paste' && row.canPaste && (
<TextPanel
className="wk-pult-paste"
label={rowName('Antwort des Kunden', row.title)}
hint="Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf."
value={pasteValue}
onChange={(value) => onField(row.id, 'paste', value)}
submitLabel="Einfügen"
onSubmit={sendPaste}
busy={busy}
/>
)}
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
</li>
)
@@ -219,6 +341,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
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 [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
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]
@@ -231,12 +354,17 @@ export default function PultList({ api, me }) {
setFinished('')
}
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
const onFinished = useCallback((id, message) => {
setFinished(message)
setDrafts((d) => {
const { [id]: _gone, ...rest } = d
return rest
})
setFields((f) => {
const { [id]: _gone, ...rest } = f
return rest
})
}, [])
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
@@ -265,7 +393,9 @@ export default function PultList({ api, me }) {
item={item}
row={row}
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
fields={Object.hasOwn(fields, row.id) ? fields[row.id] : NO_FIELDS}
onText={setText}
onField={setField}
refresh={refresh}
onFinished={onFinished}
onDetails={setDetailId}