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:
1 parent
be2e6786b6
commit
533091c333
7 files changed
+1001
-48
No files matched your search
@@ -5,11 +5,13 @@ import EmptyState from '../ui/EmptyState'
|
|||||||
import { copyTextChecked } from '../CopyableCredential'
|
import { copyTextChecked } from '../CopyableCredential'
|
||||||
import { useEmployees } from '../../hooks/useEmployees'
|
import { useEmployees } from '../../hooks/useEmployees'
|
||||||
import { usePultFeed } from '../../hooks/usePultFeed'
|
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'
|
import ProspectPanel from './ProspectPanel'
|
||||||
|
|
||||||
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick
|
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ bzw. „Wirklich freigeben?“ nach dem ersten Klick
|
||||||
const ACTION_FAILED = 'Das hat nicht geklappt.'
|
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
|
||||||
|
|
||||||
// Leerzustand je Filter
|
// Leerzustand je Filter
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
@@ -42,12 +44,29 @@ function rowName(label, title, joiner = ': ') {
|
|||||||
return t === '' ? label : `${label}${joiner}${t}`
|
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
|
// 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
|
// 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.
|
// 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 [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 [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 busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
|
||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
@@ -57,10 +76,10 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
|
|||||||
return () => { aliveRef.current = false }
|
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(() => {
|
useEffect(() => {
|
||||||
if (!confirming) return undefined
|
if (confirming === null) return undefined
|
||||||
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS)
|
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [confirming])
|
}, [confirming])
|
||||||
|
|
||||||
@@ -68,7 +87,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
|
|||||||
if (busyRef.current) return false
|
if (busyRef.current) return false
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setConfirming(false)
|
setConfirming(null)
|
||||||
setNote(null)
|
setNote(null)
|
||||||
return true
|
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.
|
// 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) => {
|
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
|
||||||
if (!begin()) return
|
// 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 {
|
try {
|
||||||
await call()
|
const result = await call()
|
||||||
if (finished) onFinished(row.id, `„${row.title}“ ${done}`)
|
const message = typeof done === 'function' ? done(result) : done
|
||||||
else if (aliveRef.current) setNote({ kind: 'ok', text: done })
|
if (finished) onFinished(row.id, `„${row.title}“ ${message}`)
|
||||||
|
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
|
||||||
|
ok = true
|
||||||
} catch (err) {
|
} 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()
|
await refresh()
|
||||||
end()
|
end()
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
|
// 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 = () => {
|
const discard = () => {
|
||||||
if (!confirming) {
|
if (confirming !== 'discard') {
|
||||||
setConfirming(true)
|
setConfirming('discard')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
|
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 { prospect } = row
|
||||||
const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
|
const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
|
||||||
const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].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)}>
|
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)}>
|
||||||
{row.canSend && row.kind === 'mail_antwort' && (
|
{row.canSend && row.kind === 'mail_antwort' && (
|
||||||
mail
|
mail
|
||||||
@@ -195,19 +264,72 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
|
|||||||
Als gesendet markieren
|
Als gesendet markieren
|
||||||
</Button>
|
</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 && (
|
{row.canDone && (
|
||||||
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
|
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
|
||||||
Erledigt
|
Erledigt
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.canDiscard && (
|
{row.canDiscard && (
|
||||||
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
|
<>
|
||||||
{confirming ? 'Wirklich verwerfen?' : 'Verwerfen'}
|
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
|
||||||
</Button>
|
{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>
|
</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>}
|
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@@ -219,6 +341,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
|
|||||||
export default function PultList({ api, me }) {
|
export default function PultList({ api, me }) {
|
||||||
const [filterId, setFilterId] = useState('offen')
|
const [filterId, setFilterId] = useState('offen')
|
||||||
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
|
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 [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 [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
|
||||||
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
||||||
@@ -231,12 +354,17 @@ export default function PultList({ api, me }) {
|
|||||||
setFinished('')
|
setFinished('')
|
||||||
}
|
}
|
||||||
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
|
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) => {
|
const onFinished = useCallback((id, message) => {
|
||||||
setFinished(message)
|
setFinished(message)
|
||||||
setDrafts((d) => {
|
setDrafts((d) => {
|
||||||
const { [id]: _gone, ...rest } = d
|
const { [id]: _gone, ...rest } = d
|
||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
|
setFields((f) => {
|
||||||
|
const { [id]: _gone, ...rest } = f
|
||||||
|
return rest
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
|
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
|
||||||
@@ -265,7 +393,9 @@ export default function PultList({ api, me }) {
|
|||||||
item={item}
|
item={item}
|
||||||
row={row}
|
row={row}
|
||||||
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
|
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
|
||||||
|
fields={Object.hasOwn(fields, row.id) ? fields[row.id] : NO_FIELDS}
|
||||||
onText={setText}
|
onText={setText}
|
||||||
|
onField={setField}
|
||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
onFinished={onFinished}
|
onFinished={onFinished}
|
||||||
onDetails={setDetailId}
|
onDetails={setDetailId}
|
||||||
|
|||||||
+17
-10
@@ -11,6 +11,9 @@ export const INVALID_ID_TEXT = 'Ungültige Kennung.'
|
|||||||
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
|
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
|
||||||
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
|
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
|
||||||
export const REQUEST_TIMEOUT_MS = 20000
|
export const REQUEST_TIMEOUT_MS = 20000
|
||||||
|
// Zeitgrenze für Pult-Aktionen, die im Dienst länger dauern (Mail senden, Freigabe einreihen, Bau anlegen): nach 20 s als „nicht erreichbar“
|
||||||
|
// abzubrechen, obwohl die Mail gerade hinausgeht, ließe den Menschen ein zweites Mal klicken. Alle anderen Abrufe behalten die kurze Frist.
|
||||||
|
export const ACTION_TIMEOUT_MS = 60000
|
||||||
// Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder Appwrite lehnt createJWT ab) wird so lange kein frisches JWT mehr
|
// Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder Appwrite lehnt createJWT ab) wird so lange kein frisches JWT mehr
|
||||||
// erzwungen, und lehnt Appwrite createJWT ab (HTTP-Code ab 400, z. B. 429; kein Übertragungsfehler), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt
|
// erzwungen, und lehnt Appwrite createJWT ab (HTTP-Code ab 400, z. B. 429; kein Übertragungsfehler), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt
|
||||||
// createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen und damit auch Serverpuls,
|
// createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen und damit auch Serverpuls,
|
||||||
@@ -20,7 +23,9 @@ export const AUTH_COOLDOWN_MS = 120000
|
|||||||
// Pult-Status des Dienstes (Übersicht §4.11)
|
// Pult-Status des Dienstes (Übersicht §4.11)
|
||||||
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
|
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
|
||||||
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
|
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
|
||||||
const PULT_ACTIONS = Object.freeze(['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste'])
|
const PULT_ACTIONS = Object.freeze(['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste', 'start-build'])
|
||||||
|
// Die Aktionen mit der langen Frist (ACTION_TIMEOUT_MS); bei einer Zeitüberschreitung ist offen, ob der Dienst sie ausgeführt hat
|
||||||
|
export const SLOW_PULT_ACTIONS = Object.freeze(['reply', 'approve-preview', 'start-build'])
|
||||||
|
|
||||||
const BASE = '/api/workers'
|
const BASE = '/api/workers'
|
||||||
const EVENTS_LIMIT = 200
|
const EVENTS_LIMIT = 200
|
||||||
@@ -67,22 +72,23 @@ export function createWorkersApi({
|
|||||||
getJwt,
|
getJwt,
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
timeoutMs = REQUEST_TIMEOUT_MS,
|
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||||
|
actionTimeoutMs = ACTION_TIMEOUT_MS,
|
||||||
authCooldownMs = AUTH_COOLDOWN_MS,
|
authCooldownMs = AUTH_COOLDOWN_MS,
|
||||||
now = Date.now,
|
now = Date.now,
|
||||||
}) {
|
}) {
|
||||||
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Folge kein frisches JWT erzeugt und nach einem gescheiterten createJWT gar keins (AUTH_COOLDOWN_MS)
|
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Folge kein frisches JWT erzeugt und nach einem gescheiterten createJWT gar keins (AUTH_COOLDOWN_MS)
|
||||||
let mintFailed = false // Appwrite hat createJWT abgelehnt (HTTP-Code ab 400, kein Übertragungsfehler): bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache
|
let mintFailed = false // Appwrite hat createJWT abgelehnt (HTTP-Code ab 400, kein Übertragungsfehler): bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache
|
||||||
|
|
||||||
// Führt fn(signal) mit Zeitgrenze aus: nach timeoutMs scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
|
// Führt fn(signal) mit Zeitgrenze aus: nach limitMs (Standard timeoutMs) scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
|
||||||
// abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung).
|
// abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung).
|
||||||
function withDeadline(fn) {
|
function withDeadline(fn, limitMs = timeoutMs) {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
let timer
|
let timer
|
||||||
const expired = new Promise((_, reject) => {
|
const expired = new Promise((_, reject) => {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
reject(statusError(DOWN_TEXT, 0)) // zuerst ablehnen, dann abbrechen: das Abbruch-Ergebnis von fn soll nicht gewinnen
|
reject(statusError(DOWN_TEXT, 0)) // zuerst ablehnen, dann abbrechen: das Abbruch-Ergebnis von fn soll nicht gewinnen
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}, timeoutMs)
|
}, limitMs)
|
||||||
})
|
})
|
||||||
return Promise.race([fn(controller.signal), expired]).finally(() => clearTimeout(timer))
|
return Promise.race([fn(controller.signal), expired]).finally(() => clearTimeout(timer))
|
||||||
}
|
}
|
||||||
@@ -142,23 +148,24 @@ export function createWorkersApi({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ein Versuch mit Zeitgrenze: JWT holen, senden, auswerten. { unauthorized: true } bei 401, sonst { data }.
|
// Ein Versuch mit Zeitgrenze: JWT holen, senden, auswerten. { unauthorized: true } bei 401, sonst { data }.
|
||||||
function attempt(path, method, body, options) {
|
function attempt(path, method, body, options, limitMs) {
|
||||||
return withDeadline(async (signal) => {
|
return withDeadline(async (signal) => {
|
||||||
const res = await send(path, method, body, await jwtFor(options), signal)
|
const res = await send(path, method, body, await jwtFor(options), signal)
|
||||||
if (res.status === 401) return { unauthorized: true }
|
if (res.status === 401) return { unauthorized: true }
|
||||||
freshBlockedUntil = 0 // der Dienst hat das JWT angenommen: die nächste 401-Folge darf wieder einmal erneuern
|
freshBlockedUntil = 0 // der Dienst hat das JWT angenommen: die nächste 401-Folge darf wieder einmal erneuern
|
||||||
return { data: await read(res) }
|
return { data: await read(res) }
|
||||||
})
|
}, limitMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, { method = 'GET', body } = {}) {
|
// limitMs: Zeitgrenze je Versuch (Standard timeoutMs); auch der Wiederholungsversuch nach einer 401 bekommt sie
|
||||||
let out = await attempt(path, method, body)
|
async function request(path, { method = 'GET', body, limitMs } = {}) {
|
||||||
|
let out = await attempt(path, method, body, undefined, limitMs)
|
||||||
if (out.unauthorized) {
|
if (out.unauthorized) {
|
||||||
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
|
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
|
||||||
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
|
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
|
||||||
// sonst einmal mit frischem JWT wiederholen (lehnt Appwrite createJWT ab, beginnt die Sperre in jwtFor), danach gilt die
|
// sonst einmal mit frischem JWT wiederholen (lehnt Appwrite createJWT ab, beginnt die Sperre in jwtFor), danach gilt die
|
||||||
// Sitzung als abgelaufen und die Sperre beginnt
|
// Sitzung als abgelaufen und die Sperre beginnt
|
||||||
out = await attempt(path, method, body, { fresh: true })
|
out = await attempt(path, method, body, { fresh: true }, limitMs)
|
||||||
if (out.unauthorized) {
|
if (out.unauthorized) {
|
||||||
freshBlockedUntil = now() + authCooldownMs
|
freshBlockedUntil = now() + authCooldownMs
|
||||||
throw statusError(SESSION_TEXT, 401)
|
throw statusError(SESSION_TEXT, 401)
|
||||||
@@ -178,7 +185,7 @@ export function createWorkersApi({
|
|||||||
pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`),
|
pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`),
|
||||||
pultAction: async (id, action, body) => {
|
pultAction: async (id, action, body) => {
|
||||||
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
|
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
|
||||||
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body })
|
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body, limitMs: SLOW_PULT_ACTIONS.includes(action) ? actionTimeoutMs : undefined })
|
||||||
},
|
},
|
||||||
botleads: ({ room, q, page, id } = {}) => request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`),
|
botleads: ({ room, q, page, id } = {}) => request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`),
|
||||||
prospect: async (id) => request(`/prospect/${pathId(id)}`),
|
prospect: async (id) => request(`/prospect/${pathId(id)}`),
|
||||||
|
|||||||
+84
-7
@@ -1,7 +1,10 @@
|
|||||||
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen.
|
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen,
|
||||||
|
// Prüfung der Texte und Meldungen für Antworten als Mensch, Vorschau-Freigabe und eingefügte Kundenantworten (P4).
|
||||||
// Kein React, kein Appwrite, kein window/document beim Import: unter Node testbar (loadModule). Nichts hier versendet etwas:
|
// Kein React, kein Appwrite, kein window/document beim Import: unter Node testbar (loadModule). Nichts hier versendet etwas:
|
||||||
// der Mensch schickt die Antwort selbst ab (Plattform oder eigenes Mailprogramm) und markiert sie danach als gesendet.
|
// Plattform-Antworten schickt der Mensch selbst ab (Plattform) und markiert sie danach als gesendet; Mail-Antworten und die Freigabe
|
||||||
|
// einer Vorschau gehen über den Dienst (api.pultAction), der sie durch das Sende-Tor führt.
|
||||||
|
|
||||||
|
import { SLOW_PULT_ACTIONS } from './workersApi'
|
||||||
import { roleOf } from './workersRoster'
|
import { roleOf } from './workersRoster'
|
||||||
|
|
||||||
const MINUTE_MS = 60 * 1000
|
const MINUTE_MS = 60 * 1000
|
||||||
@@ -23,6 +26,16 @@ export const PULT_KIND_LABELS = Object.freeze({
|
|||||||
// Nur diese Arten sind Antworten, die ein Mensch absendet; alle anderen schließt er mit „Erledigt“ ab
|
// Nur diese Arten sind Antworten, die ein Mensch absendet; alle anderen schließt er mit „Erledigt“ ab
|
||||||
export const SENDABLE_KINDS = Object.freeze(['plattform_antwort', 'mail_antwort'])
|
export const SENDABLE_KINDS = Object.freeze(['plattform_antwort', 'mail_antwort'])
|
||||||
|
|
||||||
|
// P4: Arten, auf die ein Mensch aus dem Pult heraus per Mail antwortet (Spiegel von REPLY_KINDS in src/gespraeche/pultMail.js des Dienstes)
|
||||||
|
export const REPLY_KINDS = Object.freeze(['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'])
|
||||||
|
// P4: Arten, zu denen ein Mensch die Antwort des Kunden von der Plattform einfügt (Dienst: POST /pult/:id/paste)
|
||||||
|
export const PASTE_KINDS = Object.freeze(['plattform_antwort'])
|
||||||
|
// Längster Text einer Antwort oder einer eingefügten Kundenantwort (Grenze des Dienstes, gezählt in Zeichen/Code Points)
|
||||||
|
export const REPLY_TEXT_MAX = 8000
|
||||||
|
export const ACTION_FAILED = 'Das hat nicht geklappt.'
|
||||||
|
// Bei reply, approve-preview und start-build ist nach einer Zeitüberschreitung offen, ob der Dienst die Aktion ausgeführt hat (D14)
|
||||||
|
export const UNCERTAIN_TEXT = 'Kann gesendet sein – Pult neu laden.'
|
||||||
|
|
||||||
// Die drei Reiter der Liste; `statuses` geht unverändert als Komma-Liste an GET /pult
|
// Die drei Reiter der Liste; `statuses` geht unverändert als Komma-Liste an GET /pult
|
||||||
export const PULT_FILTERS = Object.freeze([
|
export const PULT_FILTERS = Object.freeze([
|
||||||
Object.freeze({ id: 'offen', label: 'Wartet', statuses: 'offen,in_arbeit' }),
|
Object.freeze({ id: 'offen', label: 'Wartet', statuses: 'offen,in_arbeit' }),
|
||||||
@@ -42,6 +55,8 @@ const DONE_VIEW = Object.freeze({
|
|||||||
erledigt: Object.freeze({ label: 'Erledigt', tone: 'muted' }),
|
erledigt: Object.freeze({ label: 'Erledigt', tone: 'muted' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const PREVIEW_HOST = 'entwurf.project.webklar.com' // Vorschauen liegen nur hier (Vorschau-Server des Dienstes)
|
||||||
|
|
||||||
const MAIL_SUBJECT_MAX = 78
|
const MAIL_SUBJECT_MAX = 78
|
||||||
const MAIL_BODY_MAX = 1800
|
const MAIL_BODY_MAX = 1800
|
||||||
|
|
||||||
@@ -92,6 +107,16 @@ export function safeExternalUrl(url) {
|
|||||||
return parsed.href
|
return parsed.href
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Adresse einer Vorschau: nur https://entwurf.project.webklar.com/… (Standard-Port, ohne Zugangsdaten), sonst null. */
|
||||||
|
function previewUrl(url) {
|
||||||
|
const href = safeExternalUrl(url)
|
||||||
|
if (href === null) return null
|
||||||
|
const { hostname, port } = new URL(href)
|
||||||
|
return hostname === PREVIEW_HOST && port === '' ? href : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const filled = (value) => typeof value === 'string' && value.trim() !== ''
|
||||||
|
|
||||||
// Genau eine Adresse: ein '@', links und rechts etwas, kein Leerraum (CR/LF eingeschlossen) und keine Zeichen, die in der
|
// Genau eine Adresse: ein '@', links und rechts etwas, kein Leerraum (CR/LF eingeschlossen) und keine Zeichen, die in der
|
||||||
// mailto-Adresse einen zweiten Empfänger, weitere Kopfzeilen oder ein anderes Schema einschleusen würden (? & % , ; : # / \ < > ").
|
// mailto-Adresse einen zweiten Empfänger, weitere Kopfzeilen oder ein anderes Schema einschleusen würden (? & % , ; : # / \ < > ").
|
||||||
// Ein führendes „mailto:“ fällt über den Doppelpunkt heraus.
|
// Ein führendes „mailto:“ fällt über den Doppelpunkt heraus.
|
||||||
@@ -126,6 +151,14 @@ export function employeeName(by, employees) {
|
|||||||
return listedName(by, employees) ?? KOLLEGE
|
return listedName(by, employees) ?? KOLLEGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anzeigename einer Worker-Kennung wie `max` oder `clara` („Max (automatisch)“); ein Mensch (`mensch:<userId>`) und jede unbekannte Kennung
|
||||||
|
// ergeben null. Ein Worker darf nie als „Kollege“ erscheinen, deshalb geht eine solche Kennung nie durch listedName.
|
||||||
|
function workerLabel(by) {
|
||||||
|
if (typeof by !== 'string' || by.startsWith(MENSCH)) return null
|
||||||
|
const role = roleOf(by)
|
||||||
|
return role ? `${role.name} (automatisch)` : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wer einen Eintrag abgeschlossen hat, als Satzteil: `mensch:<userId>` über die Mitarbeiterliste („von Anna“, unbekannt „von Kollege“),
|
* Wer einen Eintrag abgeschlossen hat, als Satzteil: `mensch:<userId>` über die Mitarbeiterliste („von Anna“, unbekannt „von Kollege“),
|
||||||
* eine Kennung wie `max` (Worker, der automatisch gesendet hat, P4) mit dem Namen aus dem Rollen-Register („von Max (automatisch)“),
|
* eine Kennung wie `max` (Worker, der automatisch gesendet hat, P4) mit dem Namen aus dem Rollen-Register („von Max (automatisch)“),
|
||||||
@@ -135,8 +168,8 @@ export function employeeName(by, employees) {
|
|||||||
function doneByPhrase(by, employees) {
|
function doneByPhrase(by, employees) {
|
||||||
if (typeof by !== 'string' || by === '') return ''
|
if (typeof by !== 'string' || by === '') return ''
|
||||||
if (by.startsWith(MENSCH)) return `von ${employeeName(by, employees)}`
|
if (by.startsWith(MENSCH)) return `von ${employeeName(by, employees)}`
|
||||||
const role = roleOf(by)
|
const worker = workerLabel(by)
|
||||||
return role ? `von ${role.name} (automatisch)` : 'automatisch'
|
return worker ? `von ${worker}` : 'automatisch'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,8 +189,11 @@ export function rowModel(item, { me, nowMs, employees }) {
|
|||||||
let claimedByLabel = ''
|
let claimedByLabel = ''
|
||||||
if (held) {
|
if (held) {
|
||||||
const ownName = claimedByMe && typeof me.name === 'string' && me.name !== '' ? me.name : null // eigener Name, falls die Liste ihn nicht kennt
|
const ownName = claimedByMe && typeof me.name === 'string' && me.name !== '' ? me.name : null // eigener Name, falls die Liste ihn nicht kennt
|
||||||
claimedByLabel = listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE
|
claimedByLabel = workerLabel(item.claimedBy) ?? listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE // Max übernimmt beim Senden selbst (P4)
|
||||||
}
|
}
|
||||||
|
// P4: Antworten, Freigeben und Bauen nur an einem bearbeitbaren Eintrag, den kein anderer hält (Einfügen kennt diese Sperre nicht, siehe canPaste)
|
||||||
|
const free = editable && !claimedByOther
|
||||||
|
const freigabe = item.kind === 'vorschau_freigabe'
|
||||||
|
|
||||||
const draft = typeof item.draft === 'string' ? item.draft : ''
|
const draft = typeof item.draft === 'string' ? item.draft : ''
|
||||||
let mail = ''
|
let mail = ''
|
||||||
@@ -186,12 +222,23 @@ export function rowModel(item, { me, nowMs, employees }) {
|
|||||||
canClaim: item.status === 'offen' || item.status === 'veraltet',
|
canClaim: item.status === 'offen' || item.status === 'veraltet',
|
||||||
canSend: sendable && editable,
|
canSend: sendable && editable,
|
||||||
canDiscard: editable,
|
canDiscard: editable,
|
||||||
canDone: !sendable && editable,
|
// Erledigt würde eine Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: dort bleibt nur Freigeben oder Verwerfen (D20)
|
||||||
|
canDone: !sendable && editable && !freigabe,
|
||||||
|
// Mail-Antwort als Mensch: braucht einen Interessenten mit Adresse („Mail ohne Faden“ und Plattform-Interessenten haben keinen Mail-Weg)
|
||||||
|
canReply: REPLY_KINDS.includes(item.kind) && free && prospect !== null && filled(prospect.email),
|
||||||
|
canApprovePreview: freigabe && free,
|
||||||
|
// Bau starten wie ein „Ja“: Eintrag „unsicher“ mit Interessent; ob der noch lebt, entscheidet der Dienst (409 mit deutschem Satz)
|
||||||
|
canStartBuild: item.kind === 'unsicher' && free && filled(item.prospectId),
|
||||||
|
// Die Rückmeldung des Kunden kommt meist erst, nachdem jemand Claras Entwurf auf der Plattform gesendet hat: auch bei gesendet,
|
||||||
|
// erledigt oder von einem Kollegen übernommen. Nur ein verworfener Eintrag nimmt nichts mehr an.
|
||||||
|
canPaste: PASTE_KINDS.includes(item.kind) && item.status !== 'verworfen' && prospect !== null,
|
||||||
claimedByMe,
|
claimedByMe,
|
||||||
claimedByOther,
|
claimedByOther,
|
||||||
claimedByLabel,
|
claimedByLabel,
|
||||||
copyText: draft,
|
copyText: draft,
|
||||||
sourceHref: safeExternalUrl(item.sourceUrl),
|
// Bei vorschau_freigabe ist sourceUrl die Vorschau, keine Anzeige: die Zeile bietet dort previewHref statt „Anzeige öffnen“
|
||||||
|
sourceHref: freigabe ? null : safeExternalUrl(item.sourceUrl),
|
||||||
|
previewHref: freigabe ? previewUrl(item.sourceUrl) : null,
|
||||||
mailtoHref: mail,
|
mailtoHref: mail,
|
||||||
prospect,
|
prospect,
|
||||||
}
|
}
|
||||||
@@ -223,3 +270,33 @@ export async function copyAndOpen({ text, url, copy, open }) {
|
|||||||
}
|
}
|
||||||
return { copied: await copied, opened }
|
return { copied: await copied, opened }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Zeichen eines Textes wie der Dienst sie zählt: getrimmt, in Code Points (ein Emoji ist ein Zeichen). */
|
||||||
|
export function charCount(text) {
|
||||||
|
return typeof text === 'string' ? Array.from(text.trim()).length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Text von „Als Mensch antworten“ und „Antwort des Kunden einfügen“ vor dem Senden (beide Grenzen sind die des Dienstes).
|
||||||
|
* Ergebnis { ok: true, text } mit getrimmtem Text oder { ok: false, error } mit deutschem Satz.
|
||||||
|
*/
|
||||||
|
export function validateReplyText(text) {
|
||||||
|
const clean = typeof text === 'string' ? text.trim() : ''
|
||||||
|
if (clean === '') return { ok: false, error: 'Bitte einen Text eingeben.' }
|
||||||
|
if (charCount(clean) > REPLY_TEXT_MAX) return { ok: false, error: `Der Text ist zu lang (höchstens ${REPLY_TEXT_MAX} Zeichen).` }
|
||||||
|
return { ok: true, text: clean }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldung der Zeile bei einer gescheiterten Pult-Aktion: der Satz des Dienstes. Eine Zeitüberschreitung oder ein Netzfehler (status 0) bei
|
||||||
|
* reply, approve-preview und start-build heißt nicht „nicht gesendet“: der Dienst kann die Aktion noch ausgeführt haben (D14).
|
||||||
|
*/
|
||||||
|
export function actionErrorText(err, action) {
|
||||||
|
if (SLOW_PULT_ACTIONS.includes(action) && err?.status === 0) return UNCERTAIN_TEXT
|
||||||
|
return typeof err?.message === 'string' && err.message !== '' ? err.message : ACTION_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldung nach „Antwort des Kunden einfügen“: der Dienst antwortet mit status neu | fortgesetzt | doppelt. */
|
||||||
|
export function pasteDoneText(result) {
|
||||||
|
return result?.status === 'doppelt' ? 'Diese Antwort war schon eingefügt.' : 'Eingefügt. Nora sortiert die Antwort ein.'
|
||||||
|
}
|
||||||
@@ -199,6 +199,22 @@
|
|||||||
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
|
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
|
||||||
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
|
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
|
||||||
|
|
||||||
|
/* Aufgeklappte Felder unter den Aktionen: „Als Mensch antworten“ (reply) und „Antwort des Kunden einfügen“ (paste). Haarlinie darüber, volle Breite. */
|
||||||
|
.wk-pult-row .wk-pult-reply, .wk-pult-row .wk-pult-paste {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.wk-pult-reply .wk-hint, .wk-pult-paste .wk-hint { margin: 0; }
|
||||||
|
.wk-pult-reply .textarea, .wk-pult-paste .textarea { display: block; width: 100%; min-height: 140px; }
|
||||||
|
.wk-pult-panel-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
|
||||||
|
.wk-pult-count { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||||
|
.wk-pult-count.over { color: var(--red-fg); }
|
||||||
|
|
||||||
/* ---------- Interessenten-Panel (Dialog) ---------- */
|
/* ---------- Interessenten-Panel (Dialog) ---------- */
|
||||||
|
|
||||||
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
|
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
|
||||||
@@ -236,6 +252,9 @@
|
|||||||
.wk-pult-row { padding: 12px 0; }
|
.wk-pult-row { padding: 12px 0; }
|
||||||
.wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
|
.wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
|
||||||
.wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
|
.wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
|
||||||
|
/* Felder: Zähler und Knopf untereinander, der Knopf über die volle Breite */
|
||||||
|
.wk-pult-panel-foot { flex-direction: column; align-items: stretch; }
|
||||||
|
.wk-pult-panel-foot .btn { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Weniger Bewegung ---------- */
|
/* ---------- Weniger Bewegung ---------- */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|||||||
import { loadModule } from './load.mjs'
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
|
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
|
||||||
const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS } = await loadModule('src/lib/workersApi.js')
|
const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS, ACTION_TIMEOUT_MS, SLOW_PULT_ACTIONS } = await loadModule('src/lib/workersApi.js')
|
||||||
const { createJwtCache } = await loadModule('src/lib/serverpulsAuth.js')
|
const { createJwtCache } = await loadModule('src/lib/serverpulsAuth.js')
|
||||||
|
|
||||||
const jwts = []
|
const jwts = []
|
||||||
@@ -118,19 +118,21 @@ test('pult: ein Status, Komma-Liste oder Array (Übersicht §7); unbekannte Stat
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => {
|
test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => {
|
||||||
const f = fakeFetch(okReplies(7))
|
const f = fakeFetch(okReplies(8))
|
||||||
const api = createWorkersApi({ getJwt, fetchImpl: f })
|
const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||||
for (const bad of ['../x', 'delete', '']) {
|
for (const bad of ['../x', 'delete', '', 'start-build/../x', 'Start-Build']) {
|
||||||
await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/)
|
await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/)
|
||||||
}
|
}
|
||||||
assert.equal(f.calls.length, 0)
|
assert.equal(f.calls.length, 0)
|
||||||
for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']) {
|
for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste', 'start-build']) {
|
||||||
await api.pultAction('p1', a, a === 'reply' || a === 'paste' ? { text: 'x' } : undefined)
|
await api.pultAction('p1', a, a === 'reply' || a === 'paste' ? { text: 'x' } : undefined)
|
||||||
}
|
}
|
||||||
assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste'])
|
assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste', 'start-build'])
|
||||||
assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' }))
|
assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' }))
|
||||||
// Plattform-Folge-Antwort einfügen (P4-T34)
|
// Plattform-Folge-Antwort einfügen (P4-T34)
|
||||||
assert.deepEqual([f.calls[6].url, f.calls[6].init.method, f.calls[6].init.body], ['/api/workers/pult/p1/paste', 'POST', JSON.stringify({ text: 'x' })])
|
assert.deepEqual([f.calls[6].url, f.calls[6].init.method, f.calls[6].init.body], ['/api/workers/pult/p1/paste', 'POST', JSON.stringify({ text: 'x' })])
|
||||||
|
// Bau starten (P4-T32): POST ohne Body
|
||||||
|
assert.deepEqual([f.calls[7].url, f.calls[7].init.method, f.calls[7].init.body], ['/api/workers/pult/p1/start-build', 'POST', undefined])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => {
|
test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => {
|
||||||
@@ -192,9 +194,40 @@ test('Zeitgrenze: rechtzeitige Antworten bleiben unberührt, ein späterer Abruf
|
|||||||
assert.deepEqual(await quick.state(), {})
|
assert.deepEqual(await quick.state(), {})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Zeitgrenze: Standardwerte 20 s und 2 min', () => {
|
test('Zeitgrenze: Standardwerte 20 s und 2 min, für langsame Pult-Aktionen 60 s', () => {
|
||||||
assert.equal(REQUEST_TIMEOUT_MS, 20000)
|
assert.equal(REQUEST_TIMEOUT_MS, 20000)
|
||||||
assert.equal(AUTH_COOLDOWN_MS, 120000)
|
assert.equal(AUTH_COOLDOWN_MS, 120000)
|
||||||
|
assert.equal(ACTION_TIMEOUT_MS, 60000)
|
||||||
|
assert.deepEqual(SLOW_PULT_ACTIONS, ['reply', 'approve-preview', 'start-build'])
|
||||||
|
assert.ok(Object.isFrozen(SLOW_PULT_ACTIONS))
|
||||||
|
})
|
||||||
|
|
||||||
|
// P4-T32 (D14): Antworten, Freigeben und Bauen laufen im Dienst länger (SMTP, Warteschlange); bricht der Client nach 20 s ab, sähe der Mensch einen Fehler,
|
||||||
|
// obwohl die Mail hinausgeht. Nur diese drei Aktionen bekommen die lange Frist, alles andere bleibt bei der kurzen.
|
||||||
|
test('Zeitgrenze (D14): reply, approve-preview und start-build warten länger als die übrigen Pult-Aktionen', async () => {
|
||||||
|
const slowReply = (ms) => async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
return { ok: true, status: 200, json: async () => ({ item: { $id: 'p1' } }) }
|
||||||
|
}
|
||||||
|
// kurze Frist 30 ms, lange Frist 400 ms, Antwort nach 120 ms
|
||||||
|
for (const action of ['reply', 'approve-preview', 'start-build']) {
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: slowReply(120), timeoutMs: 30, actionTimeoutMs: 400 })
|
||||||
|
assert.deepEqual(await api.pultAction('p1', action, action === 'reply' ? { text: 'x' } : undefined), { item: { $id: 'p1' } }, action)
|
||||||
|
}
|
||||||
|
for (const action of ['claim', 'sent', 'discard', 'done', 'paste']) {
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: slowReply(120), timeoutMs: 30, actionTimeoutMs: 400 })
|
||||||
|
await assert.rejects(api.pultAction('p1', action, action === 'paste' ? { text: 'x' } : undefined), { message: DOWN, status: 0 }, action)
|
||||||
|
}
|
||||||
|
// Lesen (Liste, Zustand) behält die kurze Frist
|
||||||
|
await assert.rejects(createWorkersApi({ getJwt, fetchImpl: slowReply(120), timeoutMs: 30, actionTimeoutMs: 400 }).pult(), { message: DOWN, status: 0 })
|
||||||
|
// auch die lange Frist endet: ein hängender Abruf meldet nach ihr die Dienst-Meldung (status 0) und bricht ab
|
||||||
|
const signals = []
|
||||||
|
const hang = (url, init) => { signals.push(init.signal); return new Promise(() => {}) }
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: hang, timeoutMs: 20, actionTimeoutMs: 150 })
|
||||||
|
const t0 = Date.now()
|
||||||
|
await assert.rejects(api.pultAction('p1', 'reply', { text: 'x' }), { message: DOWN, status: 0 })
|
||||||
|
assert.ok(Date.now() - t0 >= 120, 'wartet die lange Frist ab')
|
||||||
|
assert.equal(signals[0].aborted, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('401-Folge: danach kein frisches JWT mehr, bis die Sperre abgelaufen ist (höchstens ein createJWT je Sperre)', async () => {
|
test('401-Folge: danach kein frisches JWT mehr, bis die Sperre abgelaufen ist (höchstens ein createJWT je Sperre)', async () => {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import assert from 'node:assert/strict'
|
|||||||
import { loadModule } from './load.mjs'
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
|
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
|
||||||
const { PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel, copyAndOpen } =
|
const {
|
||||||
await loadModule('src/lib/workersPult.js')
|
PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, REPLY_KINDS, PASTE_KINDS, REPLY_TEXT_MAX, UNCERTAIN_TEXT,
|
||||||
|
staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel, copyAndOpen, validateReplyText, charCount, actionErrorText, pasteDoneText,
|
||||||
|
} = await loadModule('src/lib/workersPult.js')
|
||||||
|
|
||||||
const NOW = Date.parse('2026-10-19T09:00:00Z')
|
const NOW = Date.parse('2026-10-19T09:00:00Z')
|
||||||
const MIN = 60 * 1000
|
const MIN = 60 * 1000
|
||||||
@@ -147,10 +149,13 @@ test('rowModel: veraltet bleibt übernehmbar, senden und verwerfen; Abgeschlosse
|
|||||||
m = rowModel({ ...base, status }, ctx)
|
m = rowModel({ ...base, status }, ctx)
|
||||||
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.stale.stale], [false, false, false, false, false], status)
|
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.stale.stale], [false, false, false, false, false], status)
|
||||||
}
|
}
|
||||||
for (const kind of ['vorschau_freigabe', 'unsicher', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
|
for (const kind of ['unsicher', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
|
||||||
m = rowModel({ ...base, kind, status: 'offen' }, ctx)
|
m = rowModel({ ...base, kind, status: 'offen' }, ctx)
|
||||||
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, true], kind)
|
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, true], kind)
|
||||||
}
|
}
|
||||||
|
// P4-T32 (D20): „Erledigt“ würde die Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: bei vorschau_freigabe gibt es nur Freigeben oder Verwerfen
|
||||||
|
m = rowModel({ ...base, kind: 'vorschau_freigabe', status: 'offen' }, ctx)
|
||||||
|
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, false])
|
||||||
m = rowModel({ ...base, kind: 'mail_antwort', status: 'in_arbeit' }, ctx)
|
m = rowModel({ ...base, kind: 'mail_antwort', status: 'in_arbeit' }, ctx)
|
||||||
assert.deepEqual([m.canSend, m.canDone], [true, false])
|
assert.deepEqual([m.canSend, m.canDone], [true, false])
|
||||||
})
|
})
|
||||||
@@ -277,3 +282,155 @@ test('copyAndOpen: open läuft noch im selben Zug (Nutzer-Geste), auch wenn copy
|
|||||||
const bad = await copyAndOpen({ text: 'D', url: 'javascript:alert(1)', copy: async () => {}, open: () => { opened += 1; return null } })
|
const bad = await copyAndOpen({ text: 'D', url: 'javascript:alert(1)', copy: async () => {}, open: () => { opened += 1; return null } })
|
||||||
assert.deepEqual(bad, { copied: true, opened: false }); assert.equal(opened, 0)
|
assert.deepEqual(bad, { copied: true, opened: false }); assert.equal(opened, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Bau starten, Antwort des Kunden einfügen ----
|
||||||
|
|
||||||
|
const T32_CTX = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Kenso' }] }
|
||||||
|
const PREVIEW_URL = 'https://entwurf.project.webklar.com/AAA/'
|
||||||
|
const entry = (over = {}) => ({ $id: 'e1', kind: 'unsicher', status: 'offen', title: 'T', draft: 'D', createdAt: iso(-HOUR), prospectId: 'p1', prospect: { name: 'X', email: 'x@verein.example' }, ...over })
|
||||||
|
const flags = (item, ctx = T32_CTX) => {
|
||||||
|
const m = rowModel(item, ctx)
|
||||||
|
return [m.canReply, m.canApprovePreview, m.canStartBuild, m.canPaste]
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Arten: REPLY_KINDS und PASTE_KINDS sind eingefroren und liegen im Dienst genauso', () => {
|
||||||
|
assert.deepEqual(REPLY_KINDS, ['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'])
|
||||||
|
assert.deepEqual(PASTE_KINDS, ['plattform_antwort'])
|
||||||
|
assert.ok(Object.isFrozen(REPLY_KINDS) && Object.isFrozen(PASTE_KINDS))
|
||||||
|
assert.equal(REPLY_TEXT_MAX, 8000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rowModel canReply: Art, Status, Übernahme und Mail-Kanal (Interessent mit Adresse)', () => {
|
||||||
|
// Art unsicher, offen, Interessent mit Adresse: ja; vorschau_freigabe und beschwerde: nein
|
||||||
|
assert.deepEqual(flags(entry()), [true, false, true, false])
|
||||||
|
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canReply, false)
|
||||||
|
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canApprovePreview, false)
|
||||||
|
for (const kind of REPLY_KINDS) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, true, kind)
|
||||||
|
for (const kind of ['plattform_antwort', 'vorschau_freigabe', 'beschwerde', 'bau_fehlgeschlagen', 'neue_art']) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, false, kind)
|
||||||
|
// „Mail ohne Faden“: kein Interessent ⇒ keine Antwort-Funktion; ohne Adresse (Plattform-Interessent) ebenso (Ruling D13)
|
||||||
|
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canReply, false)
|
||||||
|
assert.equal(rowModel(entry({ prospect: undefined }), T32_CTX).canReply, false)
|
||||||
|
assert.equal(rowModel(entry({ prospect: { name: 'X' } }), T32_CTX).canReply, false)
|
||||||
|
for (const email of ['', ' ', null, 7]) assert.equal(rowModel(entry({ prospect: { name: 'X', email } }), T32_CTX).canReply, false, String(email))
|
||||||
|
// Status: offen, in_arbeit (nur eigene Übernahme) und veraltet; alles Abgeschlossene nicht
|
||||||
|
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, true, status)
|
||||||
|
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canReply, true)
|
||||||
|
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canReply, false) // Kollege hat übernommen
|
||||||
|
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canReply, false) // Max sendet gerade selbst
|
||||||
|
assert.equal(rowModel(entry({ status: 'veraltet', claimedBy: 'mensch:u2' }), T32_CTX).canReply, true) // veraltet hält nichts mehr
|
||||||
|
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, false, status)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rowModel canApprovePreview und previewHref: nur Art vorschau_freigabe, gleiche Status-Regeln, nur die Entwurfs-Adresse', () => {
|
||||||
|
const freigabe = (over = {}) => entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL, ...over })
|
||||||
|
let m = rowModel(freigabe(), T32_CTX)
|
||||||
|
assert.deepEqual([m.canApprovePreview, m.canReply, m.canStartBuild, m.canPaste, m.previewHref], [true, false, false, false, PREVIEW_URL])
|
||||||
|
// dieselben Status-Regeln wie bei canReply
|
||||||
|
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, true, status)
|
||||||
|
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canApprovePreview, true)
|
||||||
|
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canApprovePreview, false)
|
||||||
|
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, false, status)
|
||||||
|
// andere Arten geben nichts frei, auch mit Entwurfs-Adresse
|
||||||
|
for (const kind of ['unsicher', 'mail_antwort', 'plattform_antwort', 'beschwerde']) {
|
||||||
|
m = rowModel(freigabe({ kind }), T32_CTX)
|
||||||
|
assert.deepEqual([m.canApprovePreview, m.previewHref], [false, null], kind)
|
||||||
|
}
|
||||||
|
// previewHref: nur https://entwurf.project.webklar.com/…, alles andere null (der Link steht neben dem Knopf „Freigeben“)
|
||||||
|
for (const bad of ['https://fremd.example/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com.fremd.example/AAA/',
|
||||||
|
'https://entwurf.project.webklar.com@fremd.example/AAA/', 'https://user:pw@entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com:8443/AAA/',
|
||||||
|
'https://www.project.webklar.com/AAA/', 'https://project.webklar.com/AAA/', '//entwurf.project.webklar.com/AAA/', '', null, undefined, 7]) {
|
||||||
|
m = rowModel(freigabe({ sourceUrl: bad }), T32_CTX)
|
||||||
|
assert.equal(m.previewHref, null, String(bad))
|
||||||
|
assert.equal(m.canApprovePreview, true, String(bad)) // der Knopf bleibt, die Adresse fehlt nur; der Dienst prüft die Freigabe selbst
|
||||||
|
}
|
||||||
|
assert.equal(rowModel(freigabe({ sourceUrl: 'HTTPS://ENTWURF.PROJECT.WEBKLAR.COM/AAA/' }), T32_CTX).previewHref, PREVIEW_URL) // vom Browser normalisiert
|
||||||
|
// abgeschlossen bleibt die Adresse im Modell (die Zeile braucht sie dort nicht)
|
||||||
|
assert.equal(rowModel(freigabe({ status: 'erledigt' }), T32_CTX).previewHref, PREVIEW_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rowModel (D20): vorschau_freigabe zeigt keine „Anzeige öffnen“-Adresse (sourceUrl ist die Vorschau) und kein „Erledigt“', () => {
|
||||||
|
const m = rowModel(entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL }), T32_CTX)
|
||||||
|
assert.deepEqual([m.sourceHref, m.canDone, m.canDiscard, m.canSend, m.canClaim], [null, false, true, false, true])
|
||||||
|
// andere Arten behalten ihre Anzeige-Adresse
|
||||||
|
assert.equal(rowModel(entry({ kind: 'plattform_antwort', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3' }), T32_CTX).sourceHref, 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rowModel canStartBuild (GB16): nur Art unsicher mit prospectId, bearbeitbar und nicht von einem anderen übernommen', () => {
|
||||||
|
assert.equal(rowModel(entry(), T32_CTX).canStartBuild, true)
|
||||||
|
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canStartBuild, true) // die Kurzfassung des Interessenten fehlt, die Id nicht: der Dienst prüft
|
||||||
|
for (const prospectId of [undefined, null, '', ' ', 7]) assert.equal(rowModel(entry({ prospectId }), T32_CTX).canStartBuild, false, String(prospectId))
|
||||||
|
for (const kind of ['mail_antwort', 'preisfrage', 'mensch_gewuenscht', 'beschwerde', 'vorschau_freigabe', 'plattform_antwort', 'bau_fehlgeschlagen']) {
|
||||||
|
assert.equal(rowModel(entry({ kind }), T32_CTX).canStartBuild, false, kind)
|
||||||
|
}
|
||||||
|
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canStartBuild, true)
|
||||||
|
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canStartBuild, false)
|
||||||
|
assert.equal(rowModel(entry({ status: 'veraltet' }), T32_CTX).canStartBuild, true)
|
||||||
|
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canStartBuild, false, status)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rowModel canPaste: Art plattform_antwort mit Interessent, in jedem Status außer verworfen, auch bei einem Kollegen', () => {
|
||||||
|
const plattform = (over = {}) => entry({ kind: 'plattform_antwort', prospect: { name: 'X' }, ...over })
|
||||||
|
for (const status of ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'erledigt']) assert.equal(rowModel(plattform({ status }), T32_CTX).canPaste, true, status)
|
||||||
|
// von einem Kollegen übernommen oder schon gesendet: bleibt einfügbar (die Rückmeldung kommt meist erst nach dem Senden)
|
||||||
|
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canPaste, true)
|
||||||
|
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canPaste, true)
|
||||||
|
assert.equal(rowModel(plattform({ status: 'verworfen' }), T32_CTX).canPaste, false)
|
||||||
|
assert.equal(rowModel(plattform({ prospect: null }), T32_CTX).canPaste, false)
|
||||||
|
assert.equal(rowModel(plattform({ prospect: undefined }), T32_CTX).canPaste, false)
|
||||||
|
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
|
||||||
|
assert.equal(rowModel(plattform({ kind }), T32_CTX).canPaste, false, kind)
|
||||||
|
}
|
||||||
|
// Einfügen und Antworten schließen sich aus: eine Plattform-Antwort geht über die Plattform, eine Mail-Art nie über das Einfügen
|
||||||
|
assert.deepEqual(flags(plattform()), [false, false, false, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rowModel: eine Übernahme durch einen Worker (max) heißt „Max (automatisch)“, nie „Kollege“ (wie bei doneBy)', () => {
|
||||||
|
const held = (claimedBy) => rowModel(entry({ kind: 'mail_antwort', status: 'in_arbeit', claimedBy }), T32_CTX)
|
||||||
|
let m = held('max')
|
||||||
|
assert.deepEqual([m.claimedByLabel, m.claimedByOther, m.claimedByMe], ['Max (automatisch)', true, false])
|
||||||
|
assert.equal(held('clara').claimedByLabel, 'Clara (automatisch)')
|
||||||
|
// Menschen wie bisher; unbekannte Kennungen (auch Objekt-Schlüssel) sind keine Worker
|
||||||
|
assert.equal(held('mensch:u2').claimedByLabel, 'Kenso')
|
||||||
|
assert.equal(held('mensch:u9').claimedByLabel, 'Kollege')
|
||||||
|
for (const by of ['constructor', '__proto__', 'system']) assert.equal(held(by).claimedByLabel, 'Kollege', by)
|
||||||
|
// ein Mensch, dessen Nutzer-Kennung zufällig wie ein Worker heißt, bleibt ein Mensch
|
||||||
|
assert.equal(held('mensch:max').claimedByLabel, 'Kollege')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateReplyText: getrimmt, leer und zu lang mit den Sätzen des Dienstes, Grenze 8000 Zeichen (Code Points)', () => {
|
||||||
|
assert.deepEqual(validateReplyText(' '), { ok: false, error: 'Bitte einen Text eingeben.' })
|
||||||
|
for (const bad of ['', '\n\t ', undefined, null, 5, {}]) assert.deepEqual(validateReplyText(bad), { ok: false, error: 'Bitte einen Text eingeben.' }, String(bad))
|
||||||
|
assert.deepEqual(validateReplyText(' Hallo '), { ok: true, text: 'Hallo' })
|
||||||
|
assert.deepEqual(validateReplyText('Zeile 1\n\nZeile 2'), { ok: true, text: 'Zeile 1\n\nZeile 2' }) // innere Umbrüche bleiben
|
||||||
|
const tooLong = { ok: false, error: 'Der Text ist zu lang (höchstens 8000 Zeichen).' }
|
||||||
|
assert.deepEqual(validateReplyText('a'.repeat(8001)), tooLong)
|
||||||
|
assert.equal(validateReplyText('a'.repeat(8000)).ok, true)
|
||||||
|
assert.equal(validateReplyText(` ${'a'.repeat(8000)} `).ok, true) // gezählt wird der getrimmte Text
|
||||||
|
// Code Points wie im Dienst: 8000 Emoji sind 8000 Zeichen (16000 UTF-16-Einheiten), 8001 sind zu viel
|
||||||
|
assert.equal(validateReplyText('😀'.repeat(8000)).ok, true)
|
||||||
|
assert.deepEqual(validateReplyText('😀'.repeat(8001)), tooLong)
|
||||||
|
assert.equal(charCount(' 😀a '), 2); assert.equal(charCount(undefined), 0); assert.equal(charCount('a'.repeat(8001)), 8001)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('actionErrorText (D14): bei reply, approve-preview und start-build heißt „keine Antwort“ nicht „nicht gesendet“', () => {
|
||||||
|
assert.equal(UNCERTAIN_TEXT, 'Kann gesendet sein – Pult neu laden.')
|
||||||
|
const down = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })
|
||||||
|
for (const action of ['reply', 'approve-preview', 'start-build']) assert.equal(actionErrorText(down, action), UNCERTAIN_TEXT, action)
|
||||||
|
// alle anderen Aktionen und alle echten Antworten des Dienstes behalten ihren Satz
|
||||||
|
for (const action of ['paste', 'claim', 'sent', 'discard', 'done', undefined]) assert.equal(actionErrorText(down, action), down.message, String(action))
|
||||||
|
const conflict = Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })
|
||||||
|
assert.equal(actionErrorText(conflict, 'reply'), 'Wird schon von einem Kollegen bearbeitet.')
|
||||||
|
assert.equal(actionErrorText(Object.assign(new Error('Anfrage zu groß.'), { status: 413 }), 'reply'), 'Anfrage zu groß.')
|
||||||
|
assert.equal(actionErrorText(Object.assign(new Error('Anmeldung abgelaufen. Bitte neu anmelden.'), { status: 401 }), 'reply'), 'Anmeldung abgelaufen. Bitte neu anmelden.')
|
||||||
|
// kein Satz da: allgemeiner Hinweis
|
||||||
|
assert.equal(actionErrorText(new Error(''), 'reply'), 'Das hat nicht geklappt.')
|
||||||
|
assert.equal(actionErrorText(undefined, 'paste'), 'Das hat nicht geklappt.')
|
||||||
|
assert.equal(actionErrorText(null, 'reply'), 'Das hat nicht geklappt.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pasteDoneText: neu und fortgesetzt melden das Einsortieren, doppelt meldet „schon eingefügt“', () => {
|
||||||
|
assert.equal(pasteDoneText({ status: 'neu', messageId: 'm1', threadId: 't1' }), 'Eingefügt. Nora sortiert die Antwort ein.')
|
||||||
|
assert.equal(pasteDoneText({ status: 'fortgesetzt' }), 'Eingefügt. Nora sortiert die Antwort ein.')
|
||||||
|
assert.equal(pasteDoneText({ status: 'doppelt' }), 'Diese Antwort war schon eingefügt.')
|
||||||
|
for (const odd of [undefined, null, {}, 'doppelt', { status: 7 }]) assert.equal(pasteDoneText(odd), 'Eingefügt. Nora sortiert die Antwort ein.', String(odd))
|
||||||
|
})
|
||||||
@@ -334,8 +334,12 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text,
|
|||||||
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
|
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
|
||||||
html = show([{ ...base, kind: 'unsicher', draft: '' }])
|
html = show([{ ...base, kind: 'unsicher', draft: '' }])
|
||||||
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
|
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
|
||||||
html = show([{ ...base, status: 'gesendet' }])
|
// gesendet: nichts mehr zu senden oder zu verwerfen; ohne Interessent bleibt die Zeile ohne Aktionen, mit Interessent nur „Antwort des Kunden einfügen“ (P4-T32, D13)
|
||||||
|
html = show([{ ...base, status: 'gesendet', prospect: null }])
|
||||||
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>'))
|
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>'))
|
||||||
|
html = show([{ ...base, status: 'gesendet' }])
|
||||||
|
assert.ok(html.includes('row-actions')); assert.ok(html.includes('Antwort des Kunden einfügen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen'))
|
||||||
|
assert.ok(!html.includes('Kopieren')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('Ich kümmere mich'))
|
||||||
|
|
||||||
// Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link
|
// Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link
|
||||||
const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' }
|
const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' }
|
||||||
@@ -419,3 +423,529 @@ test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen e
|
|||||||
}
|
}
|
||||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ----
|
||||||
|
|
||||||
|
test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit validateReplyText, Freigabe mit Rückfrage, sichere Vorschau-Adresse', () => {
|
||||||
|
const s = read('src/components/workers/PultList.jsx')
|
||||||
|
for (const t of [
|
||||||
|
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
|
||||||
|
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
|
||||||
|
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen',
|
||||||
|
'Antwort des Kunden einfügen', 'Einfügen', 'wk-pult-reply', 'wk-pult-paste',
|
||||||
|
'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.',
|
||||||
|
]) assert.ok(s.includes(t), t)
|
||||||
|
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
|
||||||
|
// jede der vier neuen Aktionen geht über den Client: Antwort mit dem geprüften Text, Freigabe und Bau ohne Body, Einfügen mit dem geprüften Text
|
||||||
|
assert.match(s, /api\.pultAction\(row\.id, 'reply', \{ text: [\w.]+ \}\)/)
|
||||||
|
assert.match(s, /api\.pultAction\(row\.id, 'approve-preview'\)/)
|
||||||
|
assert.match(s, /api\.pultAction\(row\.id, 'start-build'\)/)
|
||||||
|
assert.match(s, /api\.pultAction\(row\.id, 'paste', \{ text: [\w.]+ \}\)/)
|
||||||
|
// beide Texte werden vor dem Senden mit validateReplyText geprüft, ein Fehler erscheint als Meldung in der Zeile
|
||||||
|
assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2)
|
||||||
|
// die Vorschau-Adresse kommt aus rowModel (previewHref) und öffnet ohne Rückbezug in einem neuen Tab
|
||||||
|
assert.match(s, /href=\{row\.previewHref\}[^>]*target="_blank"[^>]*rel="noopener noreferrer"/)
|
||||||
|
// die Rückfrage vor dem Freigeben nutzt dieselbe Frist wie das Verwerfen (4 s)
|
||||||
|
assert.match(s, /CONFIRM_MS = 4000/)
|
||||||
|
// „Als Mensch antworten“ ist mit dem Entwurf vorbelegt, „Einfügen“ nie: die Felder sind getrennt und das Einfüge-Feld beginnt leer
|
||||||
|
assert.match(s, /fields\.reply \?\? text/); assert.match(s, /fields\.paste \?\? ''/)
|
||||||
|
assert.doesNotMatch(s, /fields\.paste \?\? (text|row\.copyText)/); assert.doesNotMatch(s, /fields\.reply \?\? (fields\.paste|'')/)
|
||||||
|
// das Fehlerschema der Zeile bleibt: Meldung mit msg ok|err, Fehlertext über actionErrorText (D14: Zeitüberschreitung ⇒ „Kann gesendet sein“)
|
||||||
|
assert.match(s, /actionErrorText\(err, action\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (P4-T32): Aktionsfeld erscheint auch bei gesendet, wenn eine der vier neuen Aktionen möglich ist (D13)', () => {
|
||||||
|
const s = read('src/components/workers/PultList.jsx')
|
||||||
|
assert.match(s, /row\.canSend \|\| row\.canClaim \|\| row\.canDone \|\| row\.canDiscard \|\| row\.canReply \|\| row\.canApprovePreview \|\| row\.canStartBuild \|\| row\.canPaste/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSS (P4-T32): .wk-pult-reply und .wk-pult-paste mit Haarlinie, volle Breite, auf dem Handy untereinander', () => {
|
||||||
|
const css = read('src/styles/workers.css')
|
||||||
|
assert.match(css, /\.wk-pult-reply/); assert.match(css, /\.wk-pult-paste/)
|
||||||
|
const rule = css.match(/\.wk-pult-row \.wk-pult-reply[^{]*\{[^}]*\}/)?.[0] ?? ''
|
||||||
|
assert.match(rule, /\.wk-pult-paste/); assert.match(rule, /border-top: 1px solid var\(--line\)/); assert.match(rule, /width: 100%/)
|
||||||
|
// schmale Bildschirme: die Fußzeile des Feldes (Zähler, Knopf) steht untereinander
|
||||||
|
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
|
||||||
|
assert.match(phone, /\.wk-pult-panel-foot/); assert.match(phone, /flex-direction: column/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (gerendert, P4-T32): Aktionen je Art – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => {
|
||||||
|
const { render } = await loadList()
|
||||||
|
const now = Date.now()
|
||||||
|
const iso = (ms) => new Date(ms).toISOString()
|
||||||
|
const show = (items) => {
|
||||||
|
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
|
||||||
|
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||||||
|
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||||||
|
}
|
||||||
|
const base = { $id: 'a', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: '', createdAt: iso(now - 3600e3), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
|
||||||
|
const buttonOf = (html, label) => new RegExp(`<button class="(?:btn|link-danger)[^"]*"[^>]*>${label}</button>`).test(html) // nur Knöpfe der Zeile (die Reiter „Wartet“ … „Erledigt“ haben keine Klasse btn)
|
||||||
|
try {
|
||||||
|
// unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu
|
||||||
|
let html = show([base])
|
||||||
|
for (const t of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.ok(buttonOf(html, t), t)
|
||||||
|
for (const t of ['Antwort des Kunden einfügen', 'Freigeben', 'Jetzt senden', 'wk-pult-reply', 'wk-pult-paste', '<textarea']) assert.ok(!html.includes(t), t)
|
||||||
|
assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>Als Mensch antworten<\/button>/)
|
||||||
|
|
||||||
|
// „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen
|
||||||
|
html = show([{ ...base, prospectId: undefined, prospect: null }])
|
||||||
|
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
|
||||||
|
// Interessent ohne Adresse (Plattform): kein Mail-Kanal ⇒ nicht antworten, aber bauen
|
||||||
|
html = show([{ ...base, prospect: { $id: 'p1', name: 'Verein' } }])
|
||||||
|
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen'))
|
||||||
|
// von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher
|
||||||
|
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
||||||
|
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
|
||||||
|
// von Max übernommen: „Max (automatisch)“, nie „Kollege“
|
||||||
|
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'max' }])
|
||||||
|
assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege'))
|
||||||
|
// mail_antwort: Mail-Link und Antwort-Funktion nebeneinander
|
||||||
|
html = show([{ ...base, kind: 'mail_antwort', draft: 'Guten Tag' }])
|
||||||
|
assert.ok(html.includes('E-Mail schreiben')); assert.ok(html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen'))
|
||||||
|
// preisfrage, mensch_gewuenscht: antworten
|
||||||
|
for (const kind of ['preisfrage', 'mensch_gewuenscht']) assert.ok(show([{ ...base, kind }]).includes('Als Mensch antworten'), kind)
|
||||||
|
assert.ok(!show([{ ...base, kind: 'beschwerde' }]).includes('Als Mensch antworten'))
|
||||||
|
// abgeschlossen: keine Antwort-Funktion mehr
|
||||||
|
for (const status of ['gesendet', 'erledigt', 'verworfen']) {
|
||||||
|
html = show([{ ...base, status }])
|
||||||
|
assert.ok(!html.includes('Als Mensch antworten'), status); assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(!html.includes('row-actions'), status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vorschau_freigabe: Vorschau ansehen (sicherer Link) + Freigeben & senden + Hinweis zu Verwerfen; kein Erledigt, keine Anzeige-Adresse, keine Antwort-Funktion
|
||||||
|
const freigabe = { ...base, kind: 'vorschau_freigabe', title: 'Vorschau <b>prüfen</b>', draft: 'Hallo, hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
|
||||||
|
html = show([freigabe])
|
||||||
|
// der Name trägt den Titel der Zeile (W8), Fremdtext darin ist kodiert
|
||||||
|
assert.match(html, /<a class="btn" href="https:\/\/entwurf\.project\.webklar\.com\/AAA\/" target="_blank" rel="noopener noreferrer" aria-label="Vorschau ansehen: Vorschau <b>prüfen<\/b>">Vorschau ansehen<\/a>/)
|
||||||
|
assert.ok(html.includes('Freigeben & senden')); assert.ok(buttonOf(html, 'Verwerfen')); assert.ok(html.includes('sendet die Vorschau nicht'))
|
||||||
|
for (const t of ['Anzeige öffnen', 'Als Mensch antworten', 'Vorschau bauen', 'Wirklich freigeben?']) assert.ok(!html.includes(t), t)
|
||||||
|
assert.ok(!buttonOf(html, 'Erledigt')); assert.ok(!html.includes('<b>prüfen</b>'))
|
||||||
|
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, der Knopf zum Freigeben bleibt
|
||||||
|
for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) {
|
||||||
|
html = show([{ ...freigabe, sourceUrl }])
|
||||||
|
assert.ok(!html.includes('Vorschau ansehen</a>'), sourceUrl); assert.ok(!html.includes('href="javascript:'), sourceUrl); assert.ok(!html.includes('fremd.example'), sourceUrl)
|
||||||
|
assert.ok(html.includes('Keine gültige Vorschau-Adresse hinterlegt.'), sourceUrl); assert.ok(html.includes('Freigeben & senden'), sourceUrl)
|
||||||
|
}
|
||||||
|
// freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben
|
||||||
|
assert.ok(!show([{ ...freigabe, status: 'erledigt' }]).includes('Freigeben'))
|
||||||
|
html = show([{ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
||||||
|
assert.ok(!html.includes('Freigeben')); assert.ok(buttonOf(html, 'Verwerfen'))
|
||||||
|
|
||||||
|
// plattform_antwort: „Antwort des Kunden einfügen“ in jedem Status außer verworfen, auch bei einem Kollegen; das Feld ist zu
|
||||||
|
const plattform = { ...base, kind: 'plattform_antwort', draft: 'Hallo', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||||||
|
for (const status of ['offen', 'veraltet', 'gesendet', 'erledigt']) {
|
||||||
|
html = show([{ ...plattform, status }])
|
||||||
|
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen'), status); assert.ok(!html.includes('wk-pult-paste'), status); assert.ok(!html.includes('Als Mensch antworten'), status)
|
||||||
|
}
|
||||||
|
html = show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
||||||
|
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen')); assert.ok(html.includes('übernommen von Kenso'))
|
||||||
|
assert.ok(!show([{ ...plattform, status: 'verworfen' }]).includes('Antwort des Kunden einfügen'))
|
||||||
|
assert.ok(!show([{ ...plattform, prospect: null }]).includes('Antwort des Kunden einfügen'))
|
||||||
|
assert.match(show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }]), /aria-expanded="false"[^>]*>Antwort des Kunden einfügen<\/button>/)
|
||||||
|
// in den anderen Arten gibt es kein Einfügen
|
||||||
|
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show([{ ...base, kind }]).includes('Antwort des Kunden einfügen'), kind)
|
||||||
|
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- PultList wird bedient (P4-T32): Mini-Läufer statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
|
||||||
|
// ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ----
|
||||||
|
|
||||||
|
const FAKE_UI = `
|
||||||
|
export const Fragment = Symbol('fragment')
|
||||||
|
export function jsx(type, props, key) { return { type, props: props ?? {}, key } }
|
||||||
|
export const jsxs = jsx
|
||||||
|
|
||||||
|
let current = null
|
||||||
|
let hookIndex = 0
|
||||||
|
let effects = []
|
||||||
|
|
||||||
|
const flat = (children, out = []) => {
|
||||||
|
if (Array.isArray(children)) children.forEach((c) => flat(c, out))
|
||||||
|
else if (children !== null && children !== undefined && children !== false && children !== true) out.push(children)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __mount(element) {
|
||||||
|
const instances = new Map()
|
||||||
|
let tree = null
|
||||||
|
let rendering = false
|
||||||
|
let dirty = false
|
||||||
|
|
||||||
|
const expand = (node, path, visited) => {
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||||||
|
const { type, props } = node
|
||||||
|
if (typeof type === 'function') {
|
||||||
|
visited.add(path)
|
||||||
|
let inst = instances.get(path)
|
||||||
|
if (!inst) { inst = { hooks: [], alive: true, rerender: render }; instances.set(path, inst) }
|
||||||
|
current = inst
|
||||||
|
hookIndex = 0
|
||||||
|
const out = type(props)
|
||||||
|
current = null
|
||||||
|
return out === null || out === undefined || out === false ? null : expand(out, path + '/c', visited)
|
||||||
|
}
|
||||||
|
const children = flat(props.children).map((c, i) => expand(c, path + '/' + (c && c.key != null ? 'k:' + c.key : i), visited))
|
||||||
|
return { tag: type === Fragment ? 'fragment' : type, props, children }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (rendering) { dirty = true; return }
|
||||||
|
rendering = true
|
||||||
|
do {
|
||||||
|
dirty = false
|
||||||
|
const visited = new Set()
|
||||||
|
effects = []
|
||||||
|
tree = expand(element, 'r', visited)
|
||||||
|
for (const [path, inst] of [...instances]) {
|
||||||
|
if (visited.has(path)) continue
|
||||||
|
inst.alive = false
|
||||||
|
for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
|
||||||
|
instances.delete(path)
|
||||||
|
}
|
||||||
|
for (const run of effects.splice(0)) run()
|
||||||
|
} while (dirty)
|
||||||
|
rendering = false
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
return { tree: () => tree, unmount: () => { for (const inst of instances.values()) { inst.alive = false; for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup() } instances.clear() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useState(initial) {
|
||||||
|
const inst = current
|
||||||
|
const i = hookIndex++
|
||||||
|
if (!inst.hooks[i]) inst.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
|
||||||
|
const slot = inst.hooks[i]
|
||||||
|
return [slot.value, (next) => {
|
||||||
|
if (!inst.alive) return
|
||||||
|
const value = typeof next === 'function' ? next(slot.value) : next
|
||||||
|
if (Object.is(value, slot.value)) return
|
||||||
|
slot.value = value
|
||||||
|
inst.rerender()
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
export function useRef(initial) {
|
||||||
|
const inst = current
|
||||||
|
const i = hookIndex++
|
||||||
|
if (!inst.hooks[i]) inst.hooks[i] = { current: initial }
|
||||||
|
return inst.hooks[i]
|
||||||
|
}
|
||||||
|
export function useCallback(fn, deps) {
|
||||||
|
const inst = current
|
||||||
|
const i = hookIndex++
|
||||||
|
const slot = inst.hooks[i]
|
||||||
|
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
|
||||||
|
inst.hooks[i] = { fn, deps }
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
export function useEffect(fn, deps) {
|
||||||
|
const inst = current
|
||||||
|
const i = hookIndex++
|
||||||
|
if (!inst.hooks[i]) inst.hooks[i] = { deps: undefined, cleanup: undefined, ran: false }
|
||||||
|
const slot = inst.hooks[i]
|
||||||
|
if (slot.ran && deps && slot.deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
|
||||||
|
effects.push(() => {
|
||||||
|
if (typeof slot.cleanup === 'function') slot.cleanup()
|
||||||
|
slot.deps = deps
|
||||||
|
slot.ran = true
|
||||||
|
slot.cleanup = fn()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const UI_STUBS = {
|
||||||
|
usePultFeed: 'export function usePultFeed({ statuses }) { return globalThis.__pultFeedFor(statuses) }',
|
||||||
|
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
|
||||||
|
CopyableCredential: 'export async function copyTextChecked() {}',
|
||||||
|
ProspectPanel: 'export default function ProspectPanel() { return null }', // das Panel (Dialog) gehört nicht zu diesen Tests
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUi() {
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||||
|
const plugin = {
|
||||||
|
name: 'pult-ui',
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-ui' }))
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'fake-ui' }, () => ({ contents: FAKE_UI, loader: 'js' }))
|
||||||
|
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-ui-stub' }))
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'pult-ui-stub' }, (a) => ({ contents: UI_STUBS[a.path], loader: 'js' }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const out = await esbuild.build({
|
||||||
|
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'", resolveDir: root, loader: 'js' },
|
||||||
|
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [plugin],
|
||||||
|
})
|
||||||
|
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baum durchsuchen und bedienen
|
||||||
|
const nodes = (node, test, out = []) => {
|
||||||
|
if (node === null || typeof node === 'string') return out
|
||||||
|
if (test(node)) out.push(node)
|
||||||
|
for (const child of node.children) nodes(child, test, out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
|
||||||
|
const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name)
|
||||||
|
const clickable = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
|
||||||
|
// Knöpfe der Zeilen (Liste) und der Reiter (Filter) getrennt: „Erledigt“ ist beides
|
||||||
|
const inList = (tree) => nodes(tree, (n) => n.tag === 'ul' && hasClass(n, 'wk-pult-list'))[0] ?? { tag: 'fragment', props: {}, children: [] }
|
||||||
|
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'wk-pult-filters'))[0]
|
||||||
|
const fieldOf = (tree, labelStart) => nodes(tree, (n) => n.tag === 'textarea' && String(n.props['aria-label'] ?? '').startsWith(labelStart))
|
||||||
|
const messages = (tree) => nodes(tree, (n) => n.tag === 'div' && String(n.props.className ?? '').split(' ').includes('msg')).map((n) => ({ kind: n.props.className.split(' ')[1], text: textOf(n), role: n.props.role }))
|
||||||
|
|
||||||
|
// Ein Lauf: Liste mit den Einträgen des Reiters „Wartet“ (alle anderen Reiter sind leer), fester Dienst, Takt von Hand
|
||||||
|
async function startList(items, api) {
|
||||||
|
const { __mount, jsx, PultList } = await loadUi()
|
||||||
|
const refreshes = []
|
||||||
|
const timers = new Map()
|
||||||
|
const opened = []
|
||||||
|
let nextTimer = 1
|
||||||
|
const saved = { window: globalThis.window }
|
||||||
|
globalThis.window = {
|
||||||
|
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
|
||||||
|
clearTimeout: (id) => { timers.delete(id) },
|
||||||
|
open: (...args) => { opened.push(args) },
|
||||||
|
}
|
||||||
|
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||||||
|
globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } })
|
||||||
|
const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' } }))
|
||||||
|
const view = {
|
||||||
|
refreshes, timers, opened,
|
||||||
|
tree: () => mounted.tree(),
|
||||||
|
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung; ein gesperrter Knopf löst nichts aus (wie im Browser)
|
||||||
|
// row: Nummer der Zeile, wenn mehrere Zeilen denselben Knopf haben
|
||||||
|
async click(label, row) {
|
||||||
|
const scope = row === undefined ? inList(view.tree()) : nodes(inList(view.tree()), (n) => n.tag === 'li')[row]
|
||||||
|
const found = clickable(scope, label)
|
||||||
|
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
|
||||||
|
if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} })
|
||||||
|
await settle()
|
||||||
|
},
|
||||||
|
async tab(label) {
|
||||||
|
const found = clickable(inFilters(view.tree()), label)
|
||||||
|
assert.equal(found.length, 1, `Reiter „${label}“`)
|
||||||
|
found[0].props.onClick()
|
||||||
|
await settle()
|
||||||
|
},
|
||||||
|
link: (label) => clickable(inList(view.tree()), label)[0],
|
||||||
|
has: (label) => clickable(inList(view.tree()), label).length > 0,
|
||||||
|
disabled: (label) => clickable(inList(view.tree()), label).map((n) => n.props.disabled === true),
|
||||||
|
async type(labelStart, value) {
|
||||||
|
const found = fieldOf(view.tree(), labelStart)
|
||||||
|
assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`)
|
||||||
|
found[0].props.onChange({ target: { value } })
|
||||||
|
await settle()
|
||||||
|
},
|
||||||
|
value: (labelStart) => fieldOf(view.tree(), labelStart).map((n) => n.props.value),
|
||||||
|
messages: () => messages(view.tree()),
|
||||||
|
text: () => textOf(view.tree()),
|
||||||
|
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
|
||||||
|
end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees },
|
||||||
|
}
|
||||||
|
await settle()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
// Dienst-Attrappe: jeder Aufruf wird festgehalten, die Antworten kommen der Reihe nach (Wert, Fehler oder offenes Versprechen)
|
||||||
|
function scriptedApi(...outcomes) {
|
||||||
|
const calls = []
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
pultAction: (...args) => {
|
||||||
|
calls.push(args)
|
||||||
|
const next = outcomes.shift()
|
||||||
|
if (next instanceof Error) return Promise.reject(next)
|
||||||
|
return next ?? Promise.resolve({})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const failure = (message, status) => Object.assign(new Error(message), { status })
|
||||||
|
|
||||||
|
const unsicher = { $id: 'e1', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: 'Entwurf von Clara', createdAt: new Date().toISOString(), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
|
||||||
|
|
||||||
|
test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit dem Entwurf vorbelegt, prüft den Text und zeigt die Sätze des Dienstes', async () => {
|
||||||
|
const api = scriptedApi(failure('Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'e1' } }))
|
||||||
|
const ui = await startList([unsicher], api)
|
||||||
|
try {
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), []) // zu: kein Textfeld
|
||||||
|
await ui.click('Als Mensch antworten')
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara']) // vorbelegt mit dem Entwurf
|
||||||
|
assert.ok(ui.text().includes('17 / 8000'))
|
||||||
|
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], true)
|
||||||
|
|
||||||
|
// leer ⇒ Hinweis in der Zeile, nichts geht hinaus
|
||||||
|
await ui.type('Text der Antwort', ' ')
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }])
|
||||||
|
assert.equal(api.calls.length, 0)
|
||||||
|
// zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus
|
||||||
|
await ui.type('Text der Antwort', 'a'.repeat(8001))
|
||||||
|
assert.match(textOf(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0]), /8001 \/ 8000/)
|
||||||
|
assert.ok(String(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0].props.className).includes('over'))
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }])
|
||||||
|
assert.equal(api.calls.length, 0)
|
||||||
|
|
||||||
|
// Dienst lehnt ab (409): der Satz des Dienstes erscheint als Fehler, der Text bleibt zum Wiederholen stehen, die Liste wird neu geholt
|
||||||
|
await ui.type('Text der Antwort', ' Guten Tag, gern. ')
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
|
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Guten Tag, gern.' }]]) // getrimmt, an die Id des Eintrags
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', role: 'alert' }])
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), [' Guten Tag, gern. ']); assert.equal(ui.refreshes.length, 1)
|
||||||
|
// keine Antwort (Zeitüberschreitung, status 0): die Mail kann hinausgegangen sein
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }])
|
||||||
|
assert.equal(ui.refreshes.length, 2)
|
||||||
|
// Erfolg: der Eintrag verlässt die Liste, die Meldung steht oben, das Feld ist leer geräumt (zurück beim Entwurf), die Liste wird neu geholt
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ gesendet.', role: 'status' }])
|
||||||
|
assert.equal(ui.refreshes.length, 3); assert.equal(api.calls.length, 3)
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (bedient, P4-T32): während einer Anfrage sind alle Knöpfe der Zeile gesperrt, ein zweiter Klick sendet nichts', async () => {
|
||||||
|
let finish
|
||||||
|
const pending = new Promise((resolve) => { finish = resolve })
|
||||||
|
const api = scriptedApi(pending)
|
||||||
|
const ui = await startList([unsicher], api)
|
||||||
|
try {
|
||||||
|
await ui.click('Als Mensch antworten')
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
|
assert.equal(api.calls.length, 1)
|
||||||
|
for (const label of ['Jetzt senden', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [true], label)
|
||||||
|
await ui.click('Jetzt senden'); await ui.click('Vorschau bauen')
|
||||||
|
assert.equal(api.calls.length, 1)
|
||||||
|
finish({ item: { $id: 'e1' } }); await settle()
|
||||||
|
assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false])
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld; „Vorschau bauen“ startet den Bau', async () => {
|
||||||
|
const api = scriptedApi(failure('Für diesen Interessenten gibt es schon eine Vorschau.', 409), Promise.resolve({ item: { $id: 'e1' } }))
|
||||||
|
const ui = await startList([{ ...unsicher, draft: undefined }], api)
|
||||||
|
try {
|
||||||
|
await ui.click('Als Mensch antworten')
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['']); assert.ok(ui.text().includes('0 / 8000'))
|
||||||
|
// Bau starten: ohne Body, Fehler des Dienstes als Satz, Erfolg oben bei der Liste
|
||||||
|
await ui.click('Vorschau bauen')
|
||||||
|
assert.deepEqual(api.calls, [['e1', 'start-build']])
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Für diesen Interessenten gibt es schon eine Vorschau.', role: 'alert' }])
|
||||||
|
await ui.click('Vorschau bauen')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ wird gebaut.', role: 'status' }])
|
||||||
|
assert.equal(api.calls.length, 2)
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach (4 s), zweiter sendet; keine Antwort heißt „Kann gesendet sein“', async () => {
|
||||||
|
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
|
||||||
|
const api = scriptedApi(failure('Die Vorschau ist nicht freigabebereit.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'f1' }, previewUrl: freigabe.sourceUrl }))
|
||||||
|
const ui = await startList([freigabe], api)
|
||||||
|
try {
|
||||||
|
// der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug
|
||||||
|
const link = ui.link('Vorschau ansehen')
|
||||||
|
assert.deepEqual([link.tag, link.props.href, link.props.target, link.props.rel], ['a', 'https://entwurf.project.webklar.com/AAA/', '_blank', 'noopener noreferrer'])
|
||||||
|
assert.ok(!ui.has('Anzeige öffnen')); assert.ok(!ui.has('Erledigt'))
|
||||||
|
// erster Klick: Rückfrage, nichts geht hinaus; sie verfällt nach 4 s
|
||||||
|
await ui.click('Freigeben & senden')
|
||||||
|
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(!ui.has('Freigeben & senden'))
|
||||||
|
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [4000])
|
||||||
|
ui.fireTimers(); await settle()
|
||||||
|
assert.ok(ui.has('Freigeben & senden')); assert.ok(!ui.has('Wirklich freigeben?'))
|
||||||
|
// Rückfrage und zweiter Klick ⇒ Aktion ohne Body; der Dienst lehnt ab: sein Satz erscheint
|
||||||
|
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
|
||||||
|
assert.deepEqual(api.calls, [['f1', 'approve-preview']])
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Die Vorschau ist nicht freigabebereit.', role: 'alert' }])
|
||||||
|
assert.ok(ui.has('Freigeben & senden')) // nach der Aktion beginnt die Rückfrage von vorn
|
||||||
|
// Zeitüberschreitung: Hinweis, die Liste wird neu geholt
|
||||||
|
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }]); assert.equal(ui.refreshes.length, 2)
|
||||||
|
// Erfolg
|
||||||
|
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
|
||||||
|
assert.equal(api.calls.length, 3)
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Vorschau prüfen“ freigegeben. Max sendet den Link im nächsten Sendefenster.', role: 'status' }])
|
||||||
|
// Verwerfen fragt weiterhin für sich nach, und die Rückfragen stören sich nicht
|
||||||
|
await ui.click('Verwerfen'); assert.ok(ui.has('Wirklich verwerfen?')); assert.ok(ui.has('Freigeben & senden'))
|
||||||
|
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(ui.has('Verwerfen'))
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres Feld mit Hinweis, Prüfung, Einfügen, doppelt, Fehler des Dienstes, Text bleibt beim Filterwechsel', async () => {
|
||||||
|
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||||||
|
const api = scriptedApi(Promise.resolve({ status: 'neu', messageId: 'm1', threadId: 't1' }), Promise.resolve({ status: 'doppelt', messageId: 'm1', threadId: 't1' }), failure('Dieser Kontakt ist gesperrt.', 409))
|
||||||
|
const ui = await startList([plattform], api)
|
||||||
|
try {
|
||||||
|
assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
||||||
|
await ui.click('Antwort des Kunden einfügen') // auch bei einem Kollegen, der den Eintrag hält
|
||||||
|
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // leer, nie mit Claras Entwurf vorbelegt
|
||||||
|
assert.ok(ui.text().includes('Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.'))
|
||||||
|
// leer ⇒ Hinweis; nichts geht hinaus
|
||||||
|
await ui.click('Einfügen')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]); assert.equal(api.calls.length, 0)
|
||||||
|
|
||||||
|
// einfügen: getrimmter Text an die Id des Eintrags; danach ist das Feld geleert und zu, die Zeile meldet das Einsortieren
|
||||||
|
await ui.type('Antwort des Kunden', ' Danke, das passt. ')
|
||||||
|
await ui.click('Einfügen')
|
||||||
|
assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]])
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Eingefügt. Nora sortiert die Antwort ein.', role: 'status' }])
|
||||||
|
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(ui.refreshes.length, 1)
|
||||||
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
|
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // nach dem Einfügen wieder leer
|
||||||
|
// dieselbe Antwort noch einmal: der Dienst kennt sie schon
|
||||||
|
await ui.type('Antwort des Kunden', 'Danke, das passt.')
|
||||||
|
await ui.click('Einfügen')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }])
|
||||||
|
assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
||||||
|
|
||||||
|
// Dienstfehler: Satz als Fehler, der Text bleibt stehen (auch wenn der Reiter gewechselt wird und die Zeile verschwindet)
|
||||||
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
|
await ui.type('Antwort des Kunden', 'Noch eine Antwort')
|
||||||
|
await ui.click('Einfügen')
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }])
|
||||||
|
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
||||||
|
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
||||||
|
await ui.tab('Wartet')
|
||||||
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
|
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
||||||
|
// Einfügen und Antworten sind getrennt: an einer Plattform-Antwort gibt es kein „Als Mensch antworten“
|
||||||
|
assert.ok(!ui.has('Als Mensch antworten'))
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (bedient, P4-T32): ein Text von „Als Mensch antworten“ überlebt den Wechsel des Reiters; nach dem Absenden ist er weg', async () => {
|
||||||
|
const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } }))
|
||||||
|
const ui = await startList([unsicher], api)
|
||||||
|
try {
|
||||||
|
await ui.click('Als Mensch antworten')
|
||||||
|
await ui.type('Text der Antwort', 'Mein eigener Text')
|
||||||
|
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Text der Antwort'), []) // die Zeile ist weg
|
||||||
|
await ui.tab('Wartet')
|
||||||
|
await ui.click('Als Mensch antworten')
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text'])
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
|
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]])
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultList (bedient, P4-T32): mehrere Einträge desselben Interessenten – Felder, Texte und Aktionen hängen an der Eintrags-Id', async () => {
|
||||||
|
const zweite = { ...unsicher, $id: 'e3', title: 'Zweite Rückfrage', draft: 'Entwurf drei' } // gleicher Interessent p1 wie e1
|
||||||
|
const nachfolge = { ...unsicher, $id: 'k9', kind: 'plattform_antwort', title: 'Folge-Antwort', draft: 'Claras Folge', prospect: { $id: 'p1', name: 'Verein' } }
|
||||||
|
const api = scriptedApi(Promise.resolve({ item: { $id: 'e3' } }), Promise.resolve({ status: 'neu' }))
|
||||||
|
const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api)
|
||||||
|
try {
|
||||||
|
assert.equal(nodes(ui.tree(), (n) => n.tag === 'li').length, 3)
|
||||||
|
await ui.click('Als Mensch antworten', 0)
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur Zeile 1 ist aufgeklappt
|
||||||
|
await ui.click('Als Mensch antworten', 1)
|
||||||
|
await ui.type('Text der Antwort: Zweite Rückfrage', 'Text drei')
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Text drei']) // die Texte stören sich nicht
|
||||||
|
// gesendet wird der Text der Zeile, deren Knopf gedrückt wurde, an die Id dieses Eintrags
|
||||||
|
await ui.click('Jetzt senden', 1)
|
||||||
|
assert.deepEqual(api.calls, [['e3', 'reply', { text: 'Text drei' }]])
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Entwurf drei']) // e3 ist abgeschlossen (Text zurückgesetzt), e1 unberührt
|
||||||
|
// Einfügen an der dritten Zeile: nur dort, mit der Id des Eintrags k9
|
||||||
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
|
await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern')
|
||||||
|
await ui.click('Einfügen')
|
||||||
|
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
Reference in new issue
Block a user