Hustler-Pixelwelt T9: Dein Pult (Szene, Klemmbrett mit Umschlägen, Brief mit den bisherigen Aktionen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b3fdf5d97
commit
5b5bb740c2
10 files changed
+2098
-653
No files matched your search
+115
-449
@@ -1,439 +1,87 @@
|
|||||||
|
// „Dein Pult“ (Hustler-Pixelwelt T9, Spec E11/E13/E14): der Bildschirm Freigabe. Die Szene (pult/PultSzene.jsx) füllt den Raum, davor liegt das Klemmbrett
|
||||||
|
// (pult/Klemmbrett.jsx) mit den Umschlägen der Einträge, die ein Mensch absendet, übernimmt oder abschließt; ein Klick auf einen Umschlag öffnet den Brief
|
||||||
|
// (pult/Brief.jsx) mit den bisherigen Aktionen. Nichts hier versendet selbst etwas: „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die
|
||||||
|
// Anzeige, abgeschickt wird dort von Hand. Aufbau und Klassen wie Freigabe.dc.html (Zeichenfläche) mit hk- davor; Wurzel .hk-pult.
|
||||||
|
// Die Datei bleibt PultList.jsx (Name und Default-Export wie bisher), sie hält Zustand und Fluss: Reiter, bearbeitete Texte und Feldtexte je Eintrag (sie überleben
|
||||||
|
// Schließen des Briefs, Filterwechsel und Neuladen), die Meldung nach einem Abschluss samt Fokus, den offenen Brief, das Interessenten-Panel, „Pult zuklappen“.
|
||||||
|
//
|
||||||
|
// Props: api (Dienst-API), me = { id, name } des Angemeldeten, kpis (Kennzahlen aus dem Zustand: pultOpen steuert die Zahl am Reiter „Wartet“, im Kopf und die Boten-
|
||||||
|
// Schleife), onBack() („◂ Hochhaus“). workers (Hustler-Tabelle) reicht die Seite durch, das Pult braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler.
|
||||||
|
// Kein CSS-Import und keine Bilder (die Stile hängt WorkersPage ein), Fremdtext nur als Textknoten.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import Badge from '../ui/Badge'
|
|
||||||
import Button from '../ui/Button'
|
|
||||||
import EmptyState from '../ui/EmptyState'
|
|
||||||
import { copyTextChecked } from '../CopyableCredential'
|
|
||||||
import { useEmployees } from '../../hooks/useEmployees'
|
import { useEmployees } from '../../hooks/useEmployees'
|
||||||
import { usePultFeed } from '../../hooks/usePultFeed'
|
import { usePultFeed } from '../../hooks/usePultFeed'
|
||||||
|
import { reiterZahl, wartetZahl, botenLaufen } from '../../lib/hustlerPult'
|
||||||
import { activeControl, restoreFocus } from '../../lib/workersFocus'
|
import { activeControl, restoreFocus } from '../../lib/workersFocus'
|
||||||
import {
|
import { PULT_FILTERS, rowModel } from '../../lib/workersPult'
|
||||||
ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText,
|
import Brief from './pult/Brief'
|
||||||
} from '../../lib/workersPult'
|
import Klemmbrett from './pult/Klemmbrett'
|
||||||
|
import PultSzene from './pult/PultSzene'
|
||||||
import ProspectPanel from './ProspectPanel'
|
import ProspectPanel from './ProspectPanel'
|
||||||
|
|
||||||
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
|
|
||||||
// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
|
|
||||||
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
|
|
||||||
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
|
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
|
||||||
|
|
||||||
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
|
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
|
||||||
const browserDocument = () => (typeof document === 'undefined' ? null : document)
|
const browserDocument = () => (typeof document === 'undefined' ? null : document)
|
||||||
// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
|
|
||||||
const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
|
|
||||||
|
|
||||||
// Leerzustand je Filter
|
export default function PultList({ api, me, kpis, onBack }) {
|
||||||
const EMPTY = {
|
|
||||||
offen: { title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' },
|
|
||||||
veraltet: { title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' },
|
|
||||||
erledigt: { title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neuer Tab ohne Rückbezug auf diese Seite
|
|
||||||
const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
|
|
||||||
|
|
||||||
// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was die Zeile anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
|
|
||||||
function copyNote(result, hasUrl) {
|
|
||||||
if (!hasUrl) {
|
|
||||||
return result.copied
|
|
||||||
? { kind: 'ok', text: 'Kopiert.' }
|
|
||||||
: { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
|
|
||||||
}
|
|
||||||
if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
|
|
||||||
if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
|
|
||||||
if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
|
|
||||||
return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zugänglicher Name für ein Bedienelement, das in jeder Zeile wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
|
|
||||||
// Namen“), dahinter der Titel der Zeile, damit eine Liste aller „Details“-Knöpfe im Screenreader unterscheidbar ist. Ohne Titel
|
|
||||||
// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
|
|
||||||
function rowName(label, title, joiner = ': ') {
|
|
||||||
const t = typeof title === 'string' ? title.trim() : ''
|
|
||||||
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 zwei Knöpfe; die Prüfung macht die Zeile beim Absenden.
|
|
||||||
// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
|
|
||||||
// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
|
|
||||||
function TextPanel({ className, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
|
|
||||||
const count = charCount(value)
|
|
||||||
// preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
|
|
||||||
const onKeyDown = (e) => {
|
|
||||||
if (e.key !== 'Escape' || busy) return
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={className} onKeyDown={onKeyDown}>
|
|
||||||
{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>
|
|
||||||
<div className="wk-pult-panel-btns">
|
|
||||||
<Button type="button" onClick={onClose} disabled={busy}>Abbrechen</Button>
|
|
||||||
<Button type="button" variant="primary" onClick={onSubmit} disabled={busy}>{submitLabel}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der
|
|
||||||
// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und
|
|
||||||
// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
|
|
||||||
function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFinished, onDetails }) {
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt
|
|
||||||
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
|
|
||||||
const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
|
|
||||||
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
|
|
||||||
const aliveRef = useRef(true)
|
|
||||||
const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
|
|
||||||
const rowRef = useRef(null) // die Zeile selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
|
|
||||||
const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
|
|
||||||
const wasBusy = useRef(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aliveRef.current = true
|
|
||||||
return () => { aliveRef.current = false }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
|
|
||||||
useEffect(() => {
|
|
||||||
if (confirming === null) return undefined
|
|
||||||
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
|
|
||||||
return () => window.clearTimeout(timer)
|
|
||||||
}, [confirming])
|
|
||||||
|
|
||||||
// Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an <body>. Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
|
|
||||||
// frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an die Zeile.
|
|
||||||
// Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Eine Zeile, die die Liste verlassen hat, gibt ihn nicht mehr zurück: das tut die
|
|
||||||
// Meldung oben (PultList).
|
|
||||||
useEffect(() => {
|
|
||||||
if (busy) {
|
|
||||||
wasBusy.current = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!wasBusy.current) return
|
|
||||||
wasBusy.current = false
|
|
||||||
const memory = returnTo.current
|
|
||||||
returnTo.current = null
|
|
||||||
if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
|
|
||||||
const row = rowRef.current
|
|
||||||
restoreFocus(browserDocument(), [
|
|
||||||
memory.control,
|
|
||||||
row?.querySelector(PANEL_FIELD),
|
|
||||||
memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
|
|
||||||
row,
|
|
||||||
])
|
|
||||||
}, [busy])
|
|
||||||
|
|
||||||
const begin = () => {
|
|
||||||
if (busyRef.current) return false
|
|
||||||
busyRef.current = true
|
|
||||||
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
|
|
||||||
setBusy(true)
|
|
||||||
setConfirming(null)
|
|
||||||
setNote(null)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const end = () => {
|
|
||||||
busyRef.current = false
|
|
||||||
if (aliveRef.current) setBusy(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile.
|
|
||||||
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
|
|
||||||
// approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „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 {
|
|
||||||
const result = await call()
|
|
||||||
const message = typeof done === 'function' ? done(result) : done
|
|
||||||
if (finished) onFinished(row.id, `„${row.title}“ ${message}`, returnTo.current?.control != null)
|
|
||||||
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
|
|
||||||
ok = true
|
|
||||||
} catch (err) {
|
|
||||||
if (aliveRef.current) setNote({ kind: 'err', text: actionErrorText(err, action) })
|
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
end()
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
|
|
||||||
// Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet die Zeile das ehrlich,
|
|
||||||
// statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
|
|
||||||
// Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
|
|
||||||
const copyOpen = async () => {
|
|
||||||
if (!begin()) return
|
|
||||||
const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
|
|
||||||
let next = copyNote(result, row.sourceHref !== null)
|
|
||||||
if (!row.claimedByMe) {
|
|
||||||
try {
|
|
||||||
await api.pultAction(row.id, 'claim')
|
|
||||||
} catch (err) {
|
|
||||||
const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
|
|
||||||
next = { kind: 'err', text: `${next.text} ${hint}` }
|
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
if (aliveRef.current) setNote(next)
|
|
||||||
end()
|
|
||||||
}
|
|
||||||
|
|
||||||
const discard = () => {
|
|
||||||
if (confirming !== 'discard') {
|
|
||||||
setConfirming('discard')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster).
|
|
||||||
// Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5).
|
|
||||||
const approve = () => {
|
|
||||||
if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab
|
|
||||||
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“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben
|
|
||||||
// fragt der erste Klick nach, der zweite binnen 4 s startet den Bau.
|
|
||||||
const startBuild = () => {
|
|
||||||
if (confirming !== 'build') {
|
|
||||||
setConfirming('build')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
// Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin.
|
|
||||||
// Der Knopf trägt seine Kennung in data-opens (Button reicht sie weiter; ein ref ginge an Button vorbei).
|
|
||||||
const close = (which) => {
|
|
||||||
setOpen(null)
|
|
||||||
actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendReply = () => {
|
|
||||||
const check = validateReplyText(replyValue)
|
|
||||||
if (!check.ok) {
|
|
||||||
setNote({ kind: 'err', text: check.error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
|
|
||||||
const sendPaste = () => {
|
|
||||||
const check = validateReplyText(pasteValue)
|
|
||||||
if (!check.ok) {
|
|
||||||
setNote({ kind: 'err', text: check.error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run(async () => {
|
|
||||||
const result = await api.pultAction(row.id, 'paste', { text: check.text })
|
|
||||||
onField(row.id, 'paste', '')
|
|
||||||
if (aliveRef.current) setOpen(null)
|
|
||||||
return result
|
|
||||||
}, pasteDoneText, false, 'paste')
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
|
|
||||||
|
|
||||||
const { prospect } = row
|
|
||||||
const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
|
|
||||||
const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
|
|
||||||
// Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
|
|
||||||
const mail = row.kind === 'mail_antwort' && prospect && prospect.email
|
|
||||||
? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="wk-pult-row" ref={rowRef} tabIndex={-1}>
|
|
||||||
<div className="wk-pult-head">
|
|
||||||
<Badge tone="blue">{row.kindLabel}</Badge>
|
|
||||||
<strong className="wk-pult-title">{row.title}</strong>
|
|
||||||
{row.stale.stale && <Badge tone="orange">{row.stale.label}</Badge>}
|
|
||||||
{row.statusLabel && <Badge tone={row.statusTone}>{row.statusLabel}</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="wk-pult-meta">
|
|
||||||
{row.stale.soon && <span>{row.stale.label}</span>}
|
|
||||||
{row.claimedByLabel && <span>übernommen von {row.claimedByLabel}</span>}
|
|
||||||
{/* Abgeschlossen: was daraus wurde, wer es tat und wann (das Alter ab Anlage führte dort in die Irre) */}
|
|
||||||
{row.doneLine ? <span>{row.doneLine}</span> : row.ageLabel && <span>{row.ageLabel}</span>}
|
|
||||||
{info && <span>{info}</span>}
|
|
||||||
{row.sourceHref && (
|
|
||||||
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Anzeige öffnen', row.title)}>Anzeige öffnen</a>
|
|
||||||
)}
|
|
||||||
{prospect && (
|
|
||||||
<button type="button" className="link" onClick={() => onDetails(prospect.$id)} aria-label={rowName('Details', row.title, ' zu ')}>Details</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.originalText && (
|
|
||||||
<details className="wk-pult-original">
|
|
||||||
<summary>Originalanfrage</summary>
|
|
||||||
<p className="wk-pult-original-text">{item.originalText}</p>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.canSend ? (
|
|
||||||
<textarea
|
|
||||||
className="textarea wk-pult-draft"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => onText(row.id, e.target.value)}
|
|
||||||
aria-label={rowName('Entwurf der Antwort', row.title)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
item.draft && (
|
|
||||||
<details className="wk-pult-original">
|
|
||||||
<summary>Entwurf</summary>
|
|
||||||
<p className="wk-pult-original-text">{item.draft}</p>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasActions && (
|
|
||||||
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)} ref={actionsRef}>
|
|
||||||
{row.canSend && row.kind === 'mail_antwort' && (
|
|
||||||
mail
|
|
||||||
? <a className="btn btn-primary" href={mail}>E-Mail schreiben</a>
|
|
||||||
: <span className="wk-hint">Keine gültige E-Mail-Adresse hinterlegt.</span>
|
|
||||||
)}
|
|
||||||
{row.canSend && row.kind !== 'mail_antwort' && (
|
|
||||||
<Button type="button" variant="primary" onClick={copyOpen} disabled={busy}>
|
|
||||||
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.canClaim && (
|
|
||||||
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}>
|
|
||||||
Ich kümmere mich
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.canSend && (
|
|
||||||
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)} disabled={busy || text.trim() === ''}>
|
|
||||||
Als gesendet markieren
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.canReply && (
|
|
||||||
<Button type="button" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="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. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.</span>}
|
|
||||||
<Button type="button" variant="primary" onClick={approve} disabled={busy || !row.previewHref}>
|
|
||||||
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{row.canStartBuild && (
|
|
||||||
<>
|
|
||||||
<Button type="button" onClick={startBuild} disabled={busy} title={BUILD_HINT}>
|
|
||||||
{confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'}
|
|
||||||
</Button>
|
|
||||||
{confirming === 'build' && <span className="wk-hint">{BUILD_HINT}</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{row.canPaste && (
|
|
||||||
<Button type="button" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={busy}>
|
|
||||||
Antwort des Kunden einfügen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.canDone && (
|
|
||||||
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
|
|
||||||
Erledigt
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{row.canDiscard && (
|
|
||||||
<>
|
|
||||||
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
|
|
||||||
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
|
|
||||||
</Button>
|
|
||||||
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
|
|
||||||
{row.kind === 'vorschau_freigabe' && <span className="wk-hint">„Verwerfen“ sendet die Vorschau nicht.</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{open === 'reply' && row.canReply && (
|
|
||||||
<TextPanel
|
|
||||||
className="wk-pult-reply"
|
|
||||||
label={rowName('Text der Antwort', row.title)}
|
|
||||||
value={replyValue}
|
|
||||||
onChange={(value) => onField(row.id, 'reply', value)}
|
|
||||||
submitLabel="Jetzt senden"
|
|
||||||
onSubmit={sendReply}
|
|
||||||
onClose={() => close('reply')}
|
|
||||||
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}
|
|
||||||
onClose={() => close('paste')}
|
|
||||||
busy={busy}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reiter „Freigabe“: die Einträge des Pults, die ein Mensch absendet, übernimmt oder abschließt. Nichts hier versendet selbst etwas:
|
|
||||||
// „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die Anzeige, abgeschickt wird dort von Hand.
|
|
||||||
// me = { id, name } des Angemeldeten.
|
|
||||||
export default function PultList({ api, me }) {
|
|
||||||
const [filterId, setFilterId] = useState('offen')
|
const [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 [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 (sein Umschlag ist dann schon weg)
|
||||||
|
const [spaet, setSpaet] = useState(null) // { kind, text }: Ergebnis einer Aktion, die erst nach dem Schließen ihres Briefs antwortete
|
||||||
const [focusAsk, setFocusAsk] = useState(0) // zählt die Abschlüsse, bei denen die Person den Fokus auf einem Knopf hatte: auch eine gleiche Meldung zweimal nacheinander holt ihn
|
const [focusAsk, setFocusAsk] = useState(0) // zählt die Abschlüsse, bei denen die Person den Fokus auf einem Knopf hatte: auch eine gleiche Meldung zweimal nacheinander holt ihn
|
||||||
const finishedRef = useRef(null)
|
const finishedRef = useRef(null)
|
||||||
|
const [briefId, setBriefId] = useState(null) // Eintrag, dessen Brief offen ist (null = keiner)
|
||||||
|
const openerRef = useRef(null) // der Umschlag, der den Brief geöffnet hat: dorthin geht der Fokus beim Schließen
|
||||||
|
const backFocus = useRef(false) // true: die Person hat den Brief geschlossen, der Fokus soll an den Umschlag zurück
|
||||||
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 [zu, setZu] = useState(false) // „Pult zuklappen“: nur die Szene bleibt
|
||||||
|
const listeRef = useRef(null)
|
||||||
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
||||||
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
|
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
|
||||||
const { employees } = useEmployees()
|
const { employees } = useEmployees()
|
||||||
const nowMs = Date.now()
|
const nowMs = Date.now()
|
||||||
|
|
||||||
// Verlässt eine Zeile die Liste, hängt ihr Knopf mit dem Fokus ab. Die Meldung oben nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
|
// Verlässt ein Eintrag die Liste, schließt sein Brief und die Knöpfe mit dem Fokus hängen ab. Die Meldung nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
|
||||||
// Tastatur- und Screenreader-Nutzer bleiben in der Nähe der Liste und hören die Bestätigung (Endprüfung W3).
|
// Tastatur- und Screenreader-Nutzer bleiben in der Nähe der Liste und hören die Bestätigung (Endprüfung W3).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusAsk === 0) return
|
if (focusAsk === 0) return
|
||||||
restoreFocus(browserDocument(), [finishedRef.current])
|
restoreFocus(browserDocument(), [finishedRef.current])
|
||||||
}, [focusAsk])
|
}, [focusAsk])
|
||||||
|
|
||||||
|
// Schließt die Person den Brief selbst (Schließen, Esc, Hintergrund), geht der Fokus an den Umschlag zurück (sonst an den ersten Umschlag der Liste)
|
||||||
|
useEffect(() => {
|
||||||
|
if (briefId !== null || !backFocus.current) return
|
||||||
|
backFocus.current = false
|
||||||
|
const opener = openerRef.current
|
||||||
|
openerRef.current = null
|
||||||
|
const target = opener && opener.isConnected !== false && typeof opener.focus === 'function'
|
||||||
|
? opener
|
||||||
|
: listeRef.current?.querySelector?.('.hk-umschlag')
|
||||||
|
if (target && typeof target.focus === 'function') target.focus({ preventScroll: true })
|
||||||
|
}, [briefId])
|
||||||
|
|
||||||
const chooseFilter = (id) => {
|
const chooseFilter = (id) => {
|
||||||
setFilterId(id)
|
setFilterId(id)
|
||||||
setFinished('')
|
setFinished('')
|
||||||
|
setSpaet(null)
|
||||||
}
|
}
|
||||||
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 setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
|
||||||
|
const oeffne = (id, event) => {
|
||||||
|
openerRef.current = event?.currentTarget ?? activeControl(browserDocument())
|
||||||
|
setSpaet(null)
|
||||||
|
setBriefId(id)
|
||||||
|
}
|
||||||
|
const schliesse = () => {
|
||||||
|
backFocus.current = true
|
||||||
|
setBriefId(null)
|
||||||
|
}
|
||||||
|
// Der Eintrag verlässt die Liste: sein Brief schließt (war inzwischen ein anderer offen, bleibt der), die Meldung steht oben und holt sich den Fokus
|
||||||
const onFinished = useCallback((id, message, hadFocus) => {
|
const onFinished = useCallback((id, message, hadFocus) => {
|
||||||
|
backFocus.current = false
|
||||||
|
setBriefId((current) => (current === id ? null : current))
|
||||||
setFinished(message)
|
setFinished(message)
|
||||||
if (hadFocus) setFocusAsk((n) => n + 1)
|
if (hadFocus) setFocusAsk((n) => n + 1)
|
||||||
setDrafts((d) => {
|
setDrafts((d) => {
|
||||||
@@ -445,66 +93,84 @@ export default function PultList({ api, me }) {
|
|||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
const onSpaet = useCallback((note) => setSpaet(note), [])
|
||||||
|
|
||||||
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
|
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
|
||||||
|
const offen = briefId === null ? null : rows.find(({ row }) => row.id === briefId) ?? null
|
||||||
|
// Ein Brief, dessen Eintrag nicht mehr in der Liste steht (ein Kollege war schneller), schließt sich: auf einem alten Stand wird nichts mehr gesendet
|
||||||
|
useEffect(() => {
|
||||||
|
if (briefId !== null && !loading && offen === null) {
|
||||||
|
backFocus.current = true
|
||||||
|
setBriefId(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let body
|
// Zahlen: nur der offene Reiter kennt die Länge seiner Liste; „Wartet“ sonst kpis.pultOpen (ein Abruf für drei Reiter wäre zu viel, Spec E11)
|
||||||
if (loading) {
|
const geladen = loading || (error && items.length === 0) ? null : items.length
|
||||||
body = <div className="loading">Wird geladen …</div>
|
const zahlen = Object.fromEntries(PULT_FILTERS.map((f) => [f.id, reiterZahl(f.id, { aktivId: filter.id, kpis, geladen })]))
|
||||||
} else if (rows.length === 0 && error) {
|
const kopfZahl = wartetZahl(kpis, filter.id, geladen)
|
||||||
body = (
|
|
||||||
<EmptyState
|
return (
|
||||||
title="Das Pult ist gerade nicht erreichbar"
|
<div className="hk-pult">
|
||||||
hint={<p>{error}</p>}
|
<PultSzene laeuft={botenLaufen(kpis)} />
|
||||||
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
|
|
||||||
|
<div className="hk-pult-ui">
|
||||||
|
<div className="hk-pult-kopf">
|
||||||
|
<button type="button" className="hk-knopf hk-pult-knopf" onClick={() => { if (typeof onBack === 'function') onBack() }}>
|
||||||
|
<span className="hk-ic hk-ic-zurueck" aria-hidden="true" />Hochhaus
|
||||||
|
</button>
|
||||||
|
<div className="hk-messing hk-pult-schild">
|
||||||
|
<span className="hk-ic hk-ic-briefkasten" aria-hidden="true" />
|
||||||
|
<h1 className="hk-pult-h1">Freigabe</h1>
|
||||||
|
{kopfZahl !== null && (
|
||||||
|
<span className="hk-pult-zahl"><span className="hk-nur-sr">Wartet auf dich:</span>{kopfZahl}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="hk-knopf hk-pult-knopf hk-pult-zuklappen" onClick={() => setZu((z) => !z)}>
|
||||||
|
<span className="hk-ic hk-ic-art-mail" aria-hidden="true" />{zu ? 'Pult aufklappen' : 'Pult zuklappen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!zu && (
|
||||||
|
<div className="hk-pult-unten" ref={listeRef}>
|
||||||
|
<Klemmbrett
|
||||||
|
filterId={filter.id}
|
||||||
|
onFilter={chooseFilter}
|
||||||
|
zahlen={zahlen}
|
||||||
|
laden={loading}
|
||||||
|
fehler={error}
|
||||||
|
rows={rows}
|
||||||
|
onNeuLaden={refresh}
|
||||||
|
meldung={finished}
|
||||||
|
meldungRef={finishedRef}
|
||||||
|
spaet={spaet}
|
||||||
|
onOeffne={oeffne}
|
||||||
/>
|
/>
|
||||||
)
|
</div>
|
||||||
} else if (rows.length === 0) {
|
)}
|
||||||
const empty = EMPTY[filter.id] ?? EMPTY.offen
|
</div>
|
||||||
body = <EmptyState title={empty.title} hint={<p>{empty.hint}</p>} />
|
|
||||||
} else {
|
{offen && (
|
||||||
body = (
|
<Brief
|
||||||
<ul className="wk-pult-list">
|
key={offen.row.id}
|
||||||
{rows.map(({ item, row }) => (
|
|
||||||
<PultRow
|
|
||||||
key={row.id}
|
|
||||||
api={api}
|
api={api}
|
||||||
item={item}
|
item={offen.item}
|
||||||
row={row}
|
row={offen.row}
|
||||||
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
|
text={Object.hasOwn(drafts, offen.row.id) ? drafts[offen.row.id] : offen.row.copyText}
|
||||||
fields={Object.hasOwn(fields, row.id) ? fields[row.id] : NO_FIELDS}
|
fields={Object.hasOwn(fields, offen.row.id) ? fields[offen.row.id] : NO_FIELDS}
|
||||||
onText={setText}
|
onText={setText}
|
||||||
onField={setField}
|
onField={setField}
|
||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
onFinished={onFinished}
|
onFinished={onFinished}
|
||||||
|
onClose={schliesse}
|
||||||
onDetails={setDetailId}
|
onDetails={setDetailId}
|
||||||
|
onSpaet={onSpaet}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</ul>
|
{detailId && (
|
||||||
)
|
<div className="hk-woms">
|
||||||
}
|
<ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="wk-pult-filters">
|
|
||||||
<div className="seg" role="group" aria-label="Filter">
|
|
||||||
{PULT_FILTERS.map((f) => (
|
|
||||||
<button
|
|
||||||
key={f.id}
|
|
||||||
type="button"
|
|
||||||
className={f.id === filter.id ? 'on' : undefined}
|
|
||||||
aria-pressed={f.id === filter.id}
|
|
||||||
onClick={() => chooseFilter(f.id)}
|
|
||||||
>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{finished && <div className="msg ok" role="status" tabIndex={-1} ref={finishedRef}>{finished}</div>}
|
|
||||||
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
|
||||||
{body}
|
|
||||||
{detailId && <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// Der Brief des Pults (Hustler-Pixelwelt T9, Spec E11/E13/E14): Dialog über dem Klemmbrett mit dem Inhalt des bisherigen Zeilenbausteins PultRow – alle Zustände
|
||||||
|
// (busy, confirming, open, note), alle Aktionen über api.pultAction, alle Texte, Rückfragen (4 s), Kopieren & öffnen, Mail-Link, Antworten-/Einfügen-Feld mit Zähler
|
||||||
|
// und 8000-Grenze, Original und Entwurf – nur mit neuem Markup (Brief-Papier, Stempel-Knöpfe, Klassen der Vorlage Freigabe.dc.html mit hk-). Welche Knöpfe erscheinen,
|
||||||
|
// entscheidet allein rowModel (src/lib/workersPult.js): canClaim, canSend, canReply, canPaste, canApprovePreview, canStartBuild, canDone, canDiscard,
|
||||||
|
// sourceHref, previewHref, mailtoHref, claimedByOther. Der Brief hält nur, was ihm gehört (läuft gerade etwas, Rückfrage, offenes Feld, Meldung); der bearbeitete
|
||||||
|
// Text und die Feldtexte liegen in PultList, damit sie Schließen, Filterwechsel und Neuladen überleben. api.pultAction führt aus, der Dienst entscheidet über Rechte
|
||||||
|
// und Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
|
||||||
|
//
|
||||||
|
// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf den ersten Knopf („Schließen“), Tab bleibt im Brief, Esc schließt erst ein offenes Feld, dann den Brief
|
||||||
|
// (während einer Anfrage bleibt ein offenes Feld stehen). Schließt der Brief nach einem Abschluss (onFinished), steht die Meldung in der Liste und nimmt den Fokus;
|
||||||
|
// schließt die Person ihn selbst (onClose), gibt PultList den Fokus an den Umschlag zurück. Eine Aktion, die nach dem Schließen noch antwortet, meldet ihr Ergebnis
|
||||||
|
// über onSpaet an die Liste (sonst ginge ein Fehler wie „Kann gesendet sein – Pult neu laden.“ verloren).
|
||||||
|
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { copyTextChecked } from '../../CopyableCredential'
|
||||||
|
import { umschlagModell } from '../../../lib/hustlerPult'
|
||||||
|
import { activeControl, restoreFocus } from '../../../lib/workersFocus'
|
||||||
|
import {
|
||||||
|
ACTION_FAILED, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, validateReplyText,
|
||||||
|
} from '../../../lib/workersPult'
|
||||||
|
import Marken from './Marken'
|
||||||
|
|
||||||
|
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
|
||||||
|
// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
|
||||||
|
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
|
||||||
|
|
||||||
|
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
|
||||||
|
const browserDocument = () => (typeof document === 'undefined' ? null : document)
|
||||||
|
// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
|
||||||
|
const PANEL_FIELD = '.hk-feld-panel textarea'
|
||||||
|
|
||||||
|
// Neuer Tab ohne Rückbezug auf diese Seite
|
||||||
|
const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
|
||||||
|
// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was der Brief anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
|
||||||
|
function copyNote(result, hasUrl) {
|
||||||
|
if (!hasUrl) {
|
||||||
|
return result.copied
|
||||||
|
? { kind: 'ok', text: 'Kopiert.' }
|
||||||
|
: { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
|
||||||
|
}
|
||||||
|
if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
|
||||||
|
if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
|
||||||
|
if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
|
||||||
|
return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zugänglicher Name für ein Bedienelement, das in jedem Brief wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
|
||||||
|
// Namen“), dahinter der Titel des Eintrags, damit eine Liste aller „Anzeige öffnen“-Links im Screenreader unterscheidbar ist. Ohne Titel
|
||||||
|
// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
|
||||||
|
function rowName(label, title, joiner = ': ') {
|
||||||
|
const t = typeof title === 'string' ? title.trim() : ''
|
||||||
|
return t === '' ? label : `${label}${joiner}${t}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stempel-Knopf der Vorlage (stempel + Art: haupt | normal | gruen | rot, stempel-frage während der Rückfrage, Icon ic-…). Alles Weitere (onClick, disabled, title,
|
||||||
|
// aria-expanded, data-opens) geht unverändert an den Knopf.
|
||||||
|
function Stempel({ art, icon, frage = false, aus = false, children, ...rest }) {
|
||||||
|
const klasse = `hk-stempel hk-stempel-${art}${frage ? ' hk-stempel-frage' : ''}${aus ? ' hk-stempel-aus' : ''}`
|
||||||
|
return (
|
||||||
|
<button type="button" className={klasse} {...rest}>
|
||||||
|
{icon && <span className={`hk-ic hk-ic-${icon}`} aria-hidden="true" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (hk-feld-antwort) und „Antwort des Kunden einfügen“ (hk-feld-einfuegen).
|
||||||
|
// Beschriftung, Textfeld, Hinweis, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und zwei Knöpfe; die Prüfung macht der Brief beim Absenden.
|
||||||
|
// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
|
||||||
|
// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
|
||||||
|
function TextPanel({ className, caption, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
|
||||||
|
const count = charCount(value)
|
||||||
|
const fieldRef = useRef(null)
|
||||||
|
const panelRef = useRef(null)
|
||||||
|
// Fokus ins Textfeld beim Aufklappen, ohne dass die Seite springt; danach rollt das ganze Feld (mit Zähler und Knöpfen) nur bei Bedarf ins Bild
|
||||||
|
useEffect(() => {
|
||||||
|
const el = fieldRef.current
|
||||||
|
if (!el || typeof el.focus !== 'function') return
|
||||||
|
el.focus({ preventScroll: true })
|
||||||
|
const panel = panelRef.current
|
||||||
|
if (panel && typeof panel.scrollIntoView === 'function') panel.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [])
|
||||||
|
// preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
if (e.key !== 'Escape' || busy) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`hk-feld-panel ${className}`} onKeyDown={onKeyDown} ref={panelRef}>
|
||||||
|
<label className="hk-feld-label">
|
||||||
|
<span className="hk-feld-titel">{caption}</span>
|
||||||
|
<textarea className="hk-feld" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} ref={fieldRef} />
|
||||||
|
</label>
|
||||||
|
{hint && <p className="hk-brief-hinweis">{hint}</p>}
|
||||||
|
<div className="hk-feld-fuss">
|
||||||
|
<span className={count > REPLY_TEXT_MAX ? 'hk-zaehler hk-zaehler-ueber' : 'hk-zaehler'}>{count} / {REPLY_TEXT_MAX}</span>
|
||||||
|
<div className="hk-feld-knoepfe">
|
||||||
|
<Stempel art="normal" onClick={onClose} disabled={busy}>Abbrechen</Stempel>
|
||||||
|
<Stempel art="haupt" icon="gesendet" onClick={onSubmit} disabled={busy}>{submitLabel}</Stempel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props: api (pultAction), item (Eintrag des Dienstes: Original, Entwurf), row (rowModel), text (Entwurf oder bearbeiteter Text), fields ({ reply, paste }),
|
||||||
|
// onText(id, value), onField(id, which, value), refresh() (Liste neu holen), onFinished(id, message, hadFocus) (der Eintrag verlässt die Liste: PultList schließt den Brief,
|
||||||
|
// zeigt die Meldung und nimmt den Fokus), onClose() (die Person schließt), onDetails(prospectId), onSpaet(note) (Ergebnis, das nach dem Schließen eintrifft)
|
||||||
|
export default function Brief({ api, item, row, text, fields, onText, onField, refresh, onFinished, onClose, onDetails, onSpaet }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt
|
||||||
|
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
|
||||||
|
const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
|
||||||
|
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
|
||||||
|
const briefRef = useRef(null) // der Brief selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
|
||||||
|
const ersterRef = useRef(null) // „Schließen“: Fokus beim Öffnen
|
||||||
|
const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
|
||||||
|
const wasBusy = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true
|
||||||
|
return () => { aliveRef.current = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Beim Öffnen: Fokus auf den ersten Knopf (Spec E14)
|
||||||
|
useEffect(() => {
|
||||||
|
const erster = ersterRef.current
|
||||||
|
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
|
||||||
|
useEffect(() => {
|
||||||
|
if (confirming === null) return undefined
|
||||||
|
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [confirming])
|
||||||
|
|
||||||
|
// Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an <body>. Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
|
||||||
|
// frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an den Brief.
|
||||||
|
// Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Ein Brief, der nach einem Abschluss geschlossen hat, gibt ihn nicht mehr zurück:
|
||||||
|
// das tut die Meldung in der Liste (PultList).
|
||||||
|
useEffect(() => {
|
||||||
|
if (busy) {
|
||||||
|
wasBusy.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!wasBusy.current) return
|
||||||
|
wasBusy.current = false
|
||||||
|
const memory = returnTo.current
|
||||||
|
returnTo.current = null
|
||||||
|
if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
|
||||||
|
const brief = briefRef.current
|
||||||
|
restoreFocus(browserDocument(), [
|
||||||
|
memory.control,
|
||||||
|
brief?.querySelector(PANEL_FIELD),
|
||||||
|
memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
|
||||||
|
brief,
|
||||||
|
])
|
||||||
|
}, [busy])
|
||||||
|
|
||||||
|
const begin = () => {
|
||||||
|
if (busyRef.current) return false
|
||||||
|
busyRef.current = true
|
||||||
|
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
|
||||||
|
setBusy(true)
|
||||||
|
setConfirming(null)
|
||||||
|
setNote(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const end = () => {
|
||||||
|
busyRef.current = false
|
||||||
|
if (aliveRef.current) setBusy(false)
|
||||||
|
}
|
||||||
|
// Meldung des Briefs; ist er schon zu, geht sie an die Liste
|
||||||
|
const report = (next) => {
|
||||||
|
if (aliveRef.current) setNote(next)
|
||||||
|
else if (typeof onSpaet === 'function') onSpaet(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb bei der Liste und nicht im Brief.
|
||||||
|
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
|
||||||
|
// approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „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 {
|
||||||
|
const result = await call()
|
||||||
|
const message = typeof done === 'function' ? done(result) : done
|
||||||
|
if (finished) onFinished(row.id, `„${row.title}“ ${message}`, aliveRef.current && returnTo.current?.control != null)
|
||||||
|
else report({ kind: 'ok', text: message })
|
||||||
|
ok = true
|
||||||
|
} catch (err) {
|
||||||
|
report({ kind: 'err', text: actionErrorText(err, action) })
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
end()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
|
||||||
|
// Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet der Brief das ehrlich,
|
||||||
|
// statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
|
||||||
|
// Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
|
||||||
|
const copyOpen = async () => {
|
||||||
|
if (!begin()) return
|
||||||
|
const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
|
||||||
|
let next = copyNote(result, row.sourceHref !== null)
|
||||||
|
if (!row.claimedByMe) {
|
||||||
|
try {
|
||||||
|
await api.pultAction(row.id, 'claim')
|
||||||
|
} catch (err) {
|
||||||
|
const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
|
||||||
|
next = { kind: 'err', text: `${next.text} ${hint}` }
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
report(next)
|
||||||
|
end()
|
||||||
|
}
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
if (confirming !== 'discard') {
|
||||||
|
setConfirming('discard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster).
|
||||||
|
// Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5).
|
||||||
|
const approve = () => {
|
||||||
|
if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab
|
||||||
|
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“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben
|
||||||
|
// fragt der erste Klick nach, der zweite binnen 4 s startet den Bau.
|
||||||
|
const startBuild = () => {
|
||||||
|
if (confirming !== 'build') {
|
||||||
|
setConfirming('build')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 Schließen und einen Wechsel des Filters überlebt.
|
||||||
|
const replyValue = fields.reply ?? text
|
||||||
|
const pasteValue = fields.paste ?? ''
|
||||||
|
const toggle = (which) => setOpen((current) => (current === which ? null : which))
|
||||||
|
// Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin.
|
||||||
|
// Der Knopf trägt seine Kennung in data-opens.
|
||||||
|
const close = (which) => {
|
||||||
|
setOpen(null)
|
||||||
|
actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 m = umschlagModell(row)
|
||||||
|
// Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
|
||||||
|
const mail = row.kind === 'mail_antwort' && prospect && prospect.email
|
||||||
|
? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
|
||||||
|
: ''
|
||||||
|
const original = typeof item.originalText === 'string' ? item.originalText : ''
|
||||||
|
const draft = typeof item.draft === 'string' ? item.draft : ''
|
||||||
|
|
||||||
|
// Tasten am Brief: Esc schließt erst ein offenes Feld (das Textfeld schließt sich selbst, hier gilt es für den Rest des Briefs), dann den Brief;
|
||||||
|
// Tab wandert im Kreis durch die Bedienelemente des Briefs
|
||||||
|
const taste = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (open !== null) {
|
||||||
|
if (!busy) close(open)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
const brief = briefRef.current
|
||||||
|
if (!brief || typeof brief.querySelectorAll !== 'function') return
|
||||||
|
const items = [...brief.querySelectorAll('button, textarea, a[href]')].filter((el) => !el.disabled)
|
||||||
|
if (items.length === 0) return
|
||||||
|
const doc = browserDocument()
|
||||||
|
const aktiv = doc ? doc.activeElement : null
|
||||||
|
if (!e.shiftKey && aktiv === items[items.length - 1]) {
|
||||||
|
e.preventDefault()
|
||||||
|
items[0].focus()
|
||||||
|
} else if (e.shiftKey && (aktiv === items[0] || aktiv === brief)) {
|
||||||
|
e.preventDefault()
|
||||||
|
items[items.length - 1].focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hk-brief-ueber">
|
||||||
|
<button type="button" tabIndex={-1} className="hk-brief-hinten" aria-label="Brief schließen" onClick={() => onClose()} />
|
||||||
|
<section
|
||||||
|
ref={briefRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={rowName(m.artLabel, row.title)}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="hk-brief"
|
||||||
|
onKeyDown={taste}
|
||||||
|
>
|
||||||
|
<div className="hk-brief-inhalt">
|
||||||
|
<div className="hk-brief-kopf">
|
||||||
|
{/* „Schließen“ steht im Quelltext zuerst (erster Knopf: Fokus beim Öffnen, Anfang der Fokusfalle) und sitzt per CSS rechts oben */}
|
||||||
|
<button type="button" className="hk-knopf hk-brief-zu" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
|
||||||
|
<span className="hk-ic hk-ic-schliessen" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div className="hk-brief-titelblock">
|
||||||
|
<div className="hk-brief-art"><span className={`hk-ic hk-ic-${m.icon}`} aria-hidden="true" />{m.artLabel}</div>
|
||||||
|
<h2 className="hk-brief-titel">{m.titel}</h2>
|
||||||
|
<div className="hk-brief-meta">
|
||||||
|
{m.info && <span>{m.info}</span>}
|
||||||
|
{m.alter && <span>{m.alter}</span>}
|
||||||
|
<Marken m={m} />
|
||||||
|
{row.sourceHref && (
|
||||||
|
<a className="hk-link" href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Anzeige öffnen', row.title)}>Anzeige öffnen</a>
|
||||||
|
)}
|
||||||
|
{prospect && (
|
||||||
|
<button type="button" className="hk-link" onClick={() => onDetails(prospect.$id)} aria-label={rowName('Interessent', row.title, ' zu ')}>Interessent</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{original !== '' && (
|
||||||
|
<>
|
||||||
|
<h3 className="hk-brief-h">Originalanfrage</h3>
|
||||||
|
<blockquote className="hk-brief-zitat">{original}</blockquote>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.canSend ? (
|
||||||
|
<label className="hk-feld-label">
|
||||||
|
<span className="hk-feld-titel">Entwurf der Antwort</span>
|
||||||
|
<textarea
|
||||||
|
className="hk-feld hk-brief-entwurf"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onText(row.id, e.target.value)}
|
||||||
|
aria-label={rowName('Entwurf der Antwort', row.title)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
draft !== '' && (
|
||||||
|
<>
|
||||||
|
<h3 className="hk-brief-h">Entwurf</h3>
|
||||||
|
<p className="hk-brief-lesen">{draft}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasActions ? (
|
||||||
|
<>
|
||||||
|
<h3 className="hk-brief-h hk-brief-h-aktionen">Aktionen</h3>
|
||||||
|
<div className="hk-aktionen" role="group" aria-label={rowName('Aktionen', row.title)} ref={actionsRef}>
|
||||||
|
{row.canSend && row.kind === 'mail_antwort' && (
|
||||||
|
mail
|
||||||
|
? <a className="hk-stempel hk-stempel-haupt" href={mail}><span className="hk-ic hk-ic-art-mail" aria-hidden="true" />E-Mail schreiben</a>
|
||||||
|
: <span className="hk-brief-hinweis">Keine gültige E-Mail-Adresse hinterlegt.</span>
|
||||||
|
)}
|
||||||
|
{row.canSend && row.kind !== 'mail_antwort' && (
|
||||||
|
<Stempel art="haupt" icon="kopieren" onClick={copyOpen} disabled={busy}>
|
||||||
|
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
|
||||||
|
</Stempel>
|
||||||
|
)}
|
||||||
|
{row.canApprovePreview && (
|
||||||
|
row.previewHref
|
||||||
|
? <a className="hk-stempel hk-stempel-normal" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}><span className="hk-ic hk-ic-ansehen" aria-hidden="true" />Vorschau ansehen</a>
|
||||||
|
: <span className="hk-brief-hinweis">Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.</span>
|
||||||
|
)}
|
||||||
|
{row.canClaim && (
|
||||||
|
<Stempel art="normal" icon="uebernehmen" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}>
|
||||||
|
Ich kümmere mich
|
||||||
|
</Stempel>
|
||||||
|
)}
|
||||||
|
{row.canSend && (
|
||||||
|
<Stempel
|
||||||
|
art="gruen"
|
||||||
|
icon="gesendet"
|
||||||
|
aus={text.trim() === ''}
|
||||||
|
onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)}
|
||||||
|
disabled={busy || text.trim() === ''}
|
||||||
|
>
|
||||||
|
Als gesendet markieren
|
||||||
|
</Stempel>
|
||||||
|
)}
|
||||||
|
{row.canReply && (
|
||||||
|
<Stempel art="normal" icon="antworten" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="reply" disabled={busy}>
|
||||||
|
Als Mensch antworten
|
||||||
|
</Stempel>
|
||||||
|
)}
|
||||||
|
{row.canApprovePreview && (
|
||||||
|
<Stempel art="gruen" icon="gesendet" frage={confirming === 'approve'} onClick={approve} disabled={busy || !row.previewHref}>
|
||||||
|
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
|
||||||
|
</Stempel>
|
||||||
|
)}
|
||||||
|
{row.canStartBuild && (
|
||||||
|
<>
|
||||||
|
<Stempel art="normal" icon="bauen" frage={confirming === 'build'} onClick={startBuild} disabled={busy} title={BUILD_HINT}>
|
||||||
|
{confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'}
|
||||||
|
</Stempel>
|
||||||
|
{confirming === 'build' && <span className="hk-brief-hinweis hk-brief-hinweis-bau">{BUILD_HINT}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.canPaste && (
|
||||||
|
<Stempel art="normal" icon="einfuegen" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={busy}>
|
||||||
|
Antwort des Kunden einfügen
|
||||||
|
</Stempel>
|
||||||
|
)}
|
||||||
|
{row.canDone && (
|
||||||
|
<Stempel art="normal" icon="erledigt" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
|
||||||
|
Erledigt
|
||||||
|
</Stempel>
|
||||||
|
)}
|
||||||
|
{row.canDiscard && (
|
||||||
|
<>
|
||||||
|
<Stempel art="rot" icon="verwerfen" frage={confirming === 'discard'} onClick={discard} disabled={busy}>
|
||||||
|
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
|
||||||
|
</Stempel>
|
||||||
|
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
|
||||||
|
{row.kind === 'vorschau_freigabe' && <span className="hk-brief-hinweis">„Verwerfen“ sendet die Vorschau nicht.</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="hk-brief-hinweis">Dieser Eintrag ist abgeschlossen.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open === 'reply' && row.canReply && (
|
||||||
|
<TextPanel
|
||||||
|
className="hk-feld-antwort"
|
||||||
|
caption="Text der Antwort"
|
||||||
|
label={rowName('Text der Antwort', row.title)}
|
||||||
|
value={replyValue}
|
||||||
|
onChange={(value) => onField(row.id, 'reply', value)}
|
||||||
|
submitLabel="Jetzt senden"
|
||||||
|
onSubmit={sendReply}
|
||||||
|
onClose={() => close('reply')}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{open === 'paste' && row.canPaste && (
|
||||||
|
<TextPanel
|
||||||
|
className="hk-feld-einfuegen"
|
||||||
|
caption="Antwort des Kunden"
|
||||||
|
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}
|
||||||
|
onClose={() => close('paste')}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{note && <div className={`hk-hinweis ${note.kind === 'err' ? 'hk-hinweis-err' : 'hk-hinweis-ok'}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Das Klemmbrett des Pults (Hustler-Pixelwelt T9, Spec E11): Reiter Wartet / Veraltet / Erledigt (PULT_FILTERS) mit Zahl, das Brett mit Klammer, Untertitel,
|
||||||
|
// Meldungen, Laden, Fehler mit „Neu laden“, Leerzustände und die Umschläge. Ein Umschlag ist ein Knopf und öffnet den Brief (Brief.jsx); er zeigt Art-Icon,
|
||||||
|
// Titel, Art · Alter, Ort · Score und je nach Zustand Frist, „veraltet“, Halter oder den Ergebnis-Stempel. Reine Darstellung: Daten, Zustand und Klicks
|
||||||
|
// kommen von PultList. Aufbau und Klassen wie Freigabe.dc.html (Block „Klemmbrett“) mit hk- davor. Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
|
||||||
|
//
|
||||||
|
// „von <Hustler>“ der Vorlage fehlt mit Absicht: ein Pult-Eintrag des Dienstes (Übersicht §4.11) trägt kein Feld, das den bringenden Hustler nennt
|
||||||
|
// (addPult kennt workerId nur beim Anlegen, gespeichert wird es nicht); erfunden wird nichts.
|
||||||
|
import { PULT_FILTERS } from '../../../lib/workersPult'
|
||||||
|
import { LEER, REITER_KLASSE, UNTERTITEL, reiterName, umschlagModell } from '../../../lib/hustlerPult'
|
||||||
|
import Marken from './Marken'
|
||||||
|
|
||||||
|
function Umschlag({ row, onOeffne }) {
|
||||||
|
const m = umschlagModell(row)
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button type="button" className="hk-umschlag" aria-haspopup="dialog" aria-label={m.label} onClick={(e) => onOeffne(row.id, e)}>
|
||||||
|
<span className={`hk-ic hk-ic-${m.icon} hk-umschlag-art`} aria-hidden="true" />
|
||||||
|
<span className="hk-zelle-text hk-umschlag-titel" title={m.titel}>{m.titel}</span>
|
||||||
|
<span className="hk-klein hk-umschlag-art-alter">{m.alter ? `${m.artLabel} · ${m.alter}` : m.artLabel}</span>
|
||||||
|
<span className="hk-umschlag-marken">
|
||||||
|
{m.info && <span className="hk-klein hk-klein-inline">{m.info}</span>}
|
||||||
|
<Marken m={m} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props: filterId (Id des offenen Reiters), onFilter(id), zahlen ({ [filterId]: Zahl | null }), laden, fehler (Satz des Dienstes oder null), rows ([{ row }] als
|
||||||
|
// rowModel), onNeuLaden(), meldung (Text der Erfolgsmeldung eines Abschlusses, '' = keine) mit meldungRef (nimmt den Fokus), spaet ({ kind, text } | null:
|
||||||
|
// Ergebnis einer Aktion, deren Brief schon zu war), onOeffne(id, event)
|
||||||
|
export default function Klemmbrett({ filterId, onFilter, zahlen, laden, fehler, rows, onNeuLaden, meldung, meldungRef, spaet, onOeffne }) {
|
||||||
|
let inhalt
|
||||||
|
if (laden) {
|
||||||
|
inhalt = <div className="hk-laden" role="status">Wird geladen …</div>
|
||||||
|
} else if (rows.length === 0 && fehler) {
|
||||||
|
inhalt = (
|
||||||
|
<div className="hk-leer">
|
||||||
|
<p className="hk-leer-titel">Das Pult ist gerade nicht erreichbar</p>
|
||||||
|
<p className="hk-leer-text">{fehler}</p>
|
||||||
|
<button type="button" className="hk-stempel hk-stempel-normal hk-leer-knopf" onClick={() => onNeuLaden()}>Neu laden</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (rows.length === 0) {
|
||||||
|
const leer = LEER[filterId] ?? LEER.offen
|
||||||
|
inhalt = (
|
||||||
|
<div className="hk-leer">
|
||||||
|
<p className="hk-leer-titel">{leer.title}</p>
|
||||||
|
<p className="hk-leer-text">{leer.hint}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
inhalt = (
|
||||||
|
<ol className="hk-liste">
|
||||||
|
{rows.map(({ row }) => <Umschlag key={row.id} row={row} onOeffne={onOeffne} />)}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hk-reiter-leiste" role="group" aria-label="Einträge filtern">
|
||||||
|
{PULT_FILTERS.map((f) => {
|
||||||
|
const aktiv = f.id === filterId
|
||||||
|
const zahl = zahlen[f.id] ?? null
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
className={`hk-reiter ${REITER_KLASSE[f.id] ?? ''}${aktiv ? ' hk-reiter-an' : ''}`}
|
||||||
|
aria-pressed={aktiv}
|
||||||
|
aria-label={zahl === null ? undefined : reiterName(f.label, zahl)}
|
||||||
|
onClick={() => onFilter(f.id)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
{zahl !== null && <span className="hk-zahl">{zahl}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<section className="hk-klemmbrett hk-brett" aria-label="Dein Pult">
|
||||||
|
<span className="hk-klammer" aria-hidden="true" />
|
||||||
|
<div className="hk-brett-kopf">
|
||||||
|
<h2 className="hk-brett-titel">Dein Pult</h2>
|
||||||
|
<p className="hk-brett-unter">{UNTERTITEL[filterId] ?? UNTERTITEL.offen}</p>
|
||||||
|
</div>
|
||||||
|
{meldung && <div className="hk-hinweis hk-hinweis-ok" role="status" tabIndex={-1} ref={meldungRef}>{meldung}</div>}
|
||||||
|
{spaet && <div className={`hk-hinweis ${spaet.kind === 'err' ? 'hk-hinweis-err' : 'hk-hinweis-ok'}`} role={spaet.kind === 'err' ? 'alert' : 'status'}>{spaet.text}</div>}
|
||||||
|
{fehler && rows.length > 0 && <div className="hk-hinweis hk-hinweis-warn" role="status">Verbindung gestört: {fehler} Es wird weiter versucht.</div>}
|
||||||
|
{inhalt}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Die kleinen Marken eines Pult-Eintrags (Frist, „veraltet“, Halter, Ergebnis-Stempel): im Umschlag der Liste und in der Kopfzeile des Briefs gleich.
|
||||||
|
// Klassen und Aufbau wie Freigabe.dc.html (marke, ic-klein, abdruck) mit hk- davor; die Farben stehen in styles/hustler/hustler-pult-ui.css.
|
||||||
|
// Ein Marken-Satz kommt aus umschlagModell (src/lib/hustlerPult.js). Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
|
||||||
|
|
||||||
|
// Props: m = umschlagModell(row)
|
||||||
|
export default function Marken({ m }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{m.frist && (
|
||||||
|
<span className="hk-marke hk-marke-frist"><span className="hk-ic hk-ic-klein hk-ic-sanduhr-k" aria-hidden="true" />{m.frist}</span>
|
||||||
|
)}
|
||||||
|
{m.gehalten && (
|
||||||
|
<span className="hk-marke hk-marke-halter"><span className="hk-ic hk-ic-klein hk-ic-person" aria-hidden="true" />{m.gehalten}</span>
|
||||||
|
)}
|
||||||
|
{m.veraltet && (
|
||||||
|
<span className="hk-marke hk-marke-veraltet"><span className="hk-ic hk-ic-klein hk-ic-veraltet" aria-hidden="true" />veraltet</span>
|
||||||
|
)}
|
||||||
|
{m.fertig && <span className={m.fertig.klasse}>{m.fertig.text}</span>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Die Szene hinter dem Klemmbrett (Hustler-Pixelwelt T9, Spec E11/E13/E14): 1:1 aus dem Markup der Zeichenfläche (pult/entwurf/Freigabe.dc.html, Block „Szene“),
|
||||||
|
// Klassen mit hk- davor. Raum, Fensterschnee, Lichter (Flackern), Uhr mit Sekundenzeiger, Lichterkette, Aufzugtüren, Boten-Schleife (Clara, Nora), Briefkasten-Fähnchen
|
||||||
|
// und Monitor sind reine CSS-Animationen aus styles/hustler/pult.css. Die 640 x 360 große Bühne wird „cover“ in die Fläche eingepasst (ResizeObserver, wie bindeRoot/passe
|
||||||
|
// in logik.js). Die Boten-Schleife und das Fähnchen laufen nur, wenn `laeuft` gilt (kpis.pultOpen > 0); sonst steht die Ruhe-Klasse der Vorlage (hk-pult-ruhe) am
|
||||||
|
// Rahmen und friert sie im Ruhebild ein. „Weniger Bewegung“ regelt das CSS der Vorlage. Dekoration: aria-hidden, keine Bedienelemente.
|
||||||
|
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { SZENE_START, SZENE_TEILE as T, szenePassung } from '../../../lib/hustlerPult'
|
||||||
|
|
||||||
|
const lage = (teil) => ({ left: teil.left, top: teil.top })
|
||||||
|
const flaeche = (teil) => ({ left: teil.left, top: teil.top, width: teil.width, height: teil.height })
|
||||||
|
|
||||||
|
// Props: laeuft (Boten-Schleife an, Standard aus)
|
||||||
|
export default function PultSzene({ laeuft = false }) {
|
||||||
|
const rahmen = useRef(null)
|
||||||
|
const [pass, setPass] = useState(SZENE_START)
|
||||||
|
|
||||||
|
// Einpassen: beim Einhängen und bei jeder Größenänderung des Rahmens messen (ohne ResizeObserver nur einmal)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rahmen.current
|
||||||
|
if (!el || typeof el.getBoundingClientRect !== 'function') return undefined
|
||||||
|
const passe = () => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const next = szenePassung(r.width, r.height)
|
||||||
|
if (next) setPass((prev) => (prev.transform === next.transform ? prev : next))
|
||||||
|
}
|
||||||
|
let beobachter = null
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
beobachter = new ResizeObserver(passe)
|
||||||
|
beobachter.observe(el)
|
||||||
|
}
|
||||||
|
passe()
|
||||||
|
return () => { if (beobachter) beobachter.disconnect() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={laeuft ? 'hk-pult-szene' : 'hk-pult-szene hk-pult-ruhe'} ref={rahmen} aria-hidden="true">
|
||||||
|
<div className="hk-pult-buehne" style={{ transform: pass.transform }}>
|
||||||
|
<div className="hk-sz-ebene hk-sz-bild" />
|
||||||
|
<div className="hk-pult-teil hk-pult-fenster" style={flaeche(T.fenster1)}>
|
||||||
|
<div className="hk-sz-schnee" />
|
||||||
|
<div className="hk-sz-sprossen1" />
|
||||||
|
</div>
|
||||||
|
<div className="hk-pult-teil hk-pult-fenster hk-sz-fenster2" style={flaeche(T.fenster2)}>
|
||||||
|
<div className="hk-sz-schnee" />
|
||||||
|
</div>
|
||||||
|
<div className="hk-sz-sprossen2" />
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-auflicht" style={lage(T.auflicht)} />
|
||||||
|
<div className="hk-sz-innen" />
|
||||||
|
<div className="hk-boten-weg"><span className="hk-boten-bild" /></div>
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-tuer" style={lage(T.tuer)} />
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-ruftaste" style={lage(T.ruftaste)} />
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-funke" style={lage(T.funke)} />
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-ding" style={lage(T.ding)} />
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-briefkasten" style={lage(T.briefkasten)} />
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-monitor" style={lage(T.monitor)} />
|
||||||
|
<span className="hk-sz-sekunde" />
|
||||||
|
<span className="hk-spr hk-pult-teil hk-sz-dampf" style={lage(T.dampf)} />
|
||||||
|
<span className="hk-pult-teil hk-pult-antenne hk-sz-antenne" style={lage(T.antenne)} />
|
||||||
|
<div className="hk-sz-ebene hk-sz-kette0" />
|
||||||
|
<div className="hk-sz-ebene hk-sz-kette1" />
|
||||||
|
<div className="hk-sz-ebene hk-sz-kette2" />
|
||||||
|
<div className="hk-sz-ebene hk-sz-ltisch" />
|
||||||
|
<div className="hk-sz-ebene hk-sz-l55" />
|
||||||
|
<div className="hk-sz-ebene hk-sz-l232" />
|
||||||
|
<div className="hk-sz-ebene hk-sz-lrechts" />
|
||||||
|
<div className="hk-sz-ebene hk-sz-lmond" />
|
||||||
|
</div>
|
||||||
|
<div className="hk-pult-vignette" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Reine Ableitungen des Pults in der Hustler-Pixelwelt (Spec E11/E13/E14, Plan T9): Szene (Maße, Einpassen), Boten-Schleife, Reiter und ihre Zahlen,
|
||||||
|
// Umschlag (Art-Icon, Alter, Frist, Halter, Ergebnis-Stempel, Name für Screenreader). Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule).
|
||||||
|
// Die Klassen heißen genau wie in src/styles/hustler/pult.css (alle mit hk- davor). Fachlich bleibt alles bei workersPult.js (rowModel, Filter, Texte);
|
||||||
|
// hier steht nur, wie ein schon fertiges Zeilenmodell aussieht. Keine Beispieldaten: die Texte sind UI-Text.
|
||||||
|
|
||||||
|
import { PULT_KIND_LABELS } from './workersPult.js'
|
||||||
|
|
||||||
|
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||||
|
const isCount = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// Szene (640 x 360 Bildpunkte, Vorlage Freigabe.dc.html): Lage der Einzelteile, soweit sie im Markup der Vorlage inline stehen
|
||||||
|
|
||||||
|
export const SZENE = Object.freeze({ w: 640, h: 360 })
|
||||||
|
|
||||||
|
/** Lage (left/top in Bildpunkten der Szene) und, wo die Vorlage sie nennt, Größe der Einzelteile. */
|
||||||
|
export const SZENE_TEILE = Object.freeze({
|
||||||
|
fenster1: Object.freeze({ left: 106, top: 46, width: 79, height: 55 }),
|
||||||
|
fenster2: Object.freeze({ left: 553, top: 61, width: 47, height: 100 }),
|
||||||
|
auflicht: Object.freeze({ left: 1, top: 320 }),
|
||||||
|
tuer: Object.freeze({ left: 11, top: 219 }),
|
||||||
|
ruftaste: Object.freeze({ left: 66, top: 262 }),
|
||||||
|
funke: Object.freeze({ left: 211, top: 274 }),
|
||||||
|
ding: Object.freeze({ left: 14, top: 197 }),
|
||||||
|
briefkasten: Object.freeze({ left: 229, top: 232 }),
|
||||||
|
monitor: Object.freeze({ left: 158, top: 243 }),
|
||||||
|
dampf: Object.freeze({ left: 107, top: 244 }),
|
||||||
|
antenne: Object.freeze({ left: 167, top: 52 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Anfangsmaß, bevor gemessen wurde (Vorlage: Zoom 2, keine Verschiebung). */
|
||||||
|
export const SZENE_START = Object.freeze({ s: 2, x: 0, y: 0, transform: 'translate(0.0px, 0.0px) scale(2.0000)' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einpassen „cover“ (bindeRoot/passe in logik.js): Zoom so, dass die Szene die Fläche füllt. Ist die Szene breiter als die Fläche, wird sie um 15 %
|
||||||
|
* des Überstands nach links geschoben (x = min(0, 0,15 · (Breite − 640 s))); senkrecht bleibt der Raum unten sichtbar
|
||||||
|
* (y = min(Überstand / 2, Überstand + 36 s), Überstand = Höhe − 360 s). Ergebnis { s, x, y, transform } oder null bei leerer oder unbrauchbarer Fläche.
|
||||||
|
*/
|
||||||
|
export function szenePassung(width, height) {
|
||||||
|
if (!(typeof width === 'number' && typeof height === 'number' && width > 0 && height > 0 && Number.isFinite(width) && Number.isFinite(height))) return null
|
||||||
|
const s = Math.max(width / SZENE.w, height / SZENE.h)
|
||||||
|
const ueber = height - SZENE.h * s
|
||||||
|
const x = Math.min(0, (width - SZENE.w * s) * 0.15)
|
||||||
|
const y = Math.min(ueber / 2, ueber + 36 * s)
|
||||||
|
return { s, x, y, transform: `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${s.toFixed(4)})` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur, solange etwas wartet: kpis.pultOpen > 0 (Spec E11); sonst ruht der Raum. */
|
||||||
|
export function botenLaufen(kpis) {
|
||||||
|
return isRecord(kpis) && typeof kpis.pultOpen === 'number' && Number.isFinite(kpis.pultOpen) && kpis.pultOpen > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// Klemmbrett: Reiter, Untertitel, Leerzustände
|
||||||
|
|
||||||
|
/** Klasse des Reiters je Filter-Id (Farbe des Reiters in pult.css). */
|
||||||
|
export const REITER_KLASSE = Object.freeze({ offen: 'hk-reiter-wartet', veraltet: 'hk-reiter-veraltet', erledigt: 'hk-reiter-erledigt' })
|
||||||
|
|
||||||
|
/** Untertitel des Klemmbretts je Filter (Vorlage). */
|
||||||
|
export const UNTERTITEL = Object.freeze({
|
||||||
|
offen: 'Hier wartet alles, was ein Mensch freigeben muss.',
|
||||||
|
veraltet: 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.',
|
||||||
|
erledigt: 'Was du oder das Team schon abgeschlossen habt.',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Leerzustand je Filter (Titel und Hinweis, Vorlage = die bisherigen Texte der Liste). */
|
||||||
|
export const LEER = Object.freeze({
|
||||||
|
offen: Object.freeze({ title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' }),
|
||||||
|
veraltet: Object.freeze({ title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' }),
|
||||||
|
erledigt: Object.freeze({ title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Wartet auf dich“ (Zahl im Kopf und am Reiter „Wartet“): kpis.pultOpen, wenn es eine Zahl ist; sonst, wenn der Reiter „Wartet“ offen und
|
||||||
|
* geladen ist, die Länge der Liste; sonst null (keine Zahl zeigen, kein „0“ erfinden).
|
||||||
|
*/
|
||||||
|
export function wartetZahl(kpis, aktivId, geladen) {
|
||||||
|
if (isRecord(kpis) && isCount(kpis.pultOpen)) return kpis.pultOpen
|
||||||
|
if (aktivId === 'offen' && isCount(geladen)) return geladen
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahl am Reiter (Spec E11): „Wartet“ zeigt kpis.pultOpen (dieselbe Zahl wie der Briefkasten im Haus und der Kopf des Pults; fehlt sie, am offenen Reiter die Länge
|
||||||
|
* der geladenen Liste), der offene Reiter „Veraltet“ oder „Erledigt“ die Länge seiner Liste (geladen = null, solange sie nicht da ist); die übrigen Reiter haben keine
|
||||||
|
* Zahl (sonst drei Abrufe alle 30 s). Ergebnis: Zahl oder null.
|
||||||
|
*/
|
||||||
|
export function reiterZahl(reiterId, { aktivId, kpis, geladen } = {}) {
|
||||||
|
if (reiterId === 'offen') return wartetZahl(kpis, aktivId, geladen)
|
||||||
|
if (reiterId === aktivId && isCount(geladen)) return geladen
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name des Reiters für Screenreader: „Wartet, 3 Einträge“; ohne Zahl nur das Wort. */
|
||||||
|
export function reiterName(label, zahl) {
|
||||||
|
if (!isCount(zahl)) return label
|
||||||
|
return `${label}, ${zahl} ${zahl === 1 ? 'Eintrag' : 'Einträge'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// Umschlag
|
||||||
|
|
||||||
|
/** Icon (Teil der Klasse hk-ic-<icon>) je Pult-Art; unbekannte Arten bekommen das Fragezeichen-Icon. */
|
||||||
|
export const ART_ICONS = Object.freeze({
|
||||||
|
plattform_antwort: 'art-plattform',
|
||||||
|
mail_antwort: 'art-mail',
|
||||||
|
vorschau_freigabe: 'art-vorschau',
|
||||||
|
unsicher: 'art-unsicher',
|
||||||
|
beschwerde: 'art-beschwerde',
|
||||||
|
preisfrage: 'art-preisfrage',
|
||||||
|
mensch_gewuenscht: 'art-mensch',
|
||||||
|
bau_fehlgeschlagen: 'art-bau-kaputt',
|
||||||
|
})
|
||||||
|
const ART_UNBEKANNT = 'art-unsicher'
|
||||||
|
|
||||||
|
/** Name und Icon einer Pult-Art. */
|
||||||
|
export function artView(kind) {
|
||||||
|
return {
|
||||||
|
label: Object.hasOwn(PULT_KIND_LABELS, kind) ? PULT_KIND_LABELS[kind] : String(kind ?? ''),
|
||||||
|
icon: Object.hasOwn(ART_ICONS, kind) ? ART_ICONS[kind] : ART_UNBEKANNT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DU_KUEMMERST = 'Du kümmerst dich darum.'
|
||||||
|
|
||||||
|
/** „66640 Namborn · Score 72“ aus der Kurzfassung des Interessenten (leer, wenn nichts da ist). */
|
||||||
|
export function prospectInfo(prospect) {
|
||||||
|
if (!isRecord(prospect)) return ''
|
||||||
|
const place = [prospect.postalCode, prospect.city].filter(Boolean).join(' ')
|
||||||
|
return [place, prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was ein Umschlag (und die Kopfzeile des Briefs) zeigt, aus dem fertigen Zeilenmodell (rowModel):
|
||||||
|
* art/icon, titel, alter (bei Abgeschlossenem leer: das Alter ab Anlage führte dort in die Irre, der Stempel nennt Ergebnis, Person und Zeit),
|
||||||
|
* info (Ort · Score), frist („läuft in N Std. ab“), veraltet, gehalten („Du kümmerst dich darum.“ / „übernommen von …“), fertig { text, klasse } und
|
||||||
|
* label (zugänglicher Name des Umschlags: Art: Titel, Alter, Frist, veraltet, Halter, Ergebnis).
|
||||||
|
*/
|
||||||
|
export function umschlagModell(row) {
|
||||||
|
const art = artView(row.kind)
|
||||||
|
const done = typeof row.doneLine === 'string' && row.doneLine !== ''
|
||||||
|
const alter = done ? '' : (row.ageLabel || '')
|
||||||
|
const frist = row.stale?.soon ? row.stale.label : ''
|
||||||
|
const veraltet = row.stale?.stale === true
|
||||||
|
let gehalten = ''
|
||||||
|
if (row.claimedByMe) gehalten = DU_KUEMMERST
|
||||||
|
else if (row.claimedByLabel) gehalten = `übernommen von ${row.claimedByLabel}`
|
||||||
|
const fertig = done ? { text: row.doneLine, klasse: row.statusTone === 'green' ? 'hk-abdruck hk-stempel-gruen' : 'hk-abdruck hk-stempel-grau' } : null
|
||||||
|
const titel = typeof row.title === 'string' ? row.title : ''
|
||||||
|
const teile = [titel.trim() === '' ? art.label : `${art.label}: ${titel}`]
|
||||||
|
if (alter) teile.push(alter)
|
||||||
|
if (frist) teile.push(frist)
|
||||||
|
if (veraltet) teile.push('veraltet')
|
||||||
|
if (gehalten) teile.push(row.claimedByMe ? 'du kümmerst dich darum' : gehalten)
|
||||||
|
if (fertig) teile.push(fertig.text)
|
||||||
|
return { id: row.id, artLabel: art.label, icon: art.icon, titel, alter, info: prospectInfo(row.prospect), frist, veraltet, gehalten, fertig, label: teile.join(', ') }
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/* Handgeschriebene Regeln des Pults (PultList.jsx, pult/*.jsx; Hustler-Pixelwelt T9, Spec E11/E13/E14). Nicht erzeugt: Szene, Reiter, Klemmbrett, Umschlag, Brief,
|
||||||
|
Stempel, Feld, Icons und Keyframes kommen aus pult.css (scripts/hustler-import.mjs, nie von Hand ändern). Hier stehen die Inline-Stile der Zeichenfläche
|
||||||
|
(pixelwelt-v2/pult/entwurf/Freigabe.dc.html) als Klassen sowie das, was die Vorlage nicht zeigt (Meldungen, Laden, Leerzustand, Handy). Alle Klassen beginnen mit hk-
|
||||||
|
und stehen unter der Wurzel .hk-pult. Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS); diese Datei kommt nach pult.css. */
|
||||||
|
|
||||||
|
/* ---------- Wurzel: füllt den Inhaltsbereich (Vorlage: 100 % x 100vh, nichts scrollt außer der Liste und dem Brief) ---------- */
|
||||||
|
.hk-pult { position: absolute; inset: 0; overflow: hidden; color: #2b2230; font-size: 15px; line-height: 20px; }
|
||||||
|
|
||||||
|
/* ---------- Szene: Rahmen, Bühne (640 x 360, per transform eingepasst), Teile, Vignette ---------- */
|
||||||
|
.hk-pult .hk-pult-szene { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
|
||||||
|
.hk-pult .hk-pult-buehne { position: absolute; left: 0; top: 0; width: 640px; height: 360px; transform-origin: 0 0; image-rendering: pixelated; pointer-events: none; }
|
||||||
|
.hk-pult .hk-pult-teil { position: absolute; }
|
||||||
|
.hk-pult .hk-pult-fenster { overflow: hidden; }
|
||||||
|
.hk-pult .hk-pult-antenne { width: 1px; height: 1px; background: #e2483d; }
|
||||||
|
.hk-pult .hk-pult-vignette {
|
||||||
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Oberfläche vor der Szene: Kopfzeile, Reiter, Klemmbrett ---------- */
|
||||||
|
.hk-pult .hk-pult-ui {
|
||||||
|
position: absolute; top: 14px; right: 16px; bottom: 14px; width: min(716px, calc(100% - 32px));
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.hk-pult .hk-pult-kopf { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 6px; }
|
||||||
|
.hk-pult .hk-pult-knopf { padding: 0 10px 4px 4px; }
|
||||||
|
.hk-pult .hk-pult-zuklappen { margin-left: auto; }
|
||||||
|
.hk-pult .hk-pult-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; }
|
||||||
|
.hk-pult .hk-pult-h1 { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; }
|
||||||
|
.hk-pult .hk-pult-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; }
|
||||||
|
/* Nur für Screenreader (Beschriftung vor der Zahl) */
|
||||||
|
.hk-pult .hk-nur-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||||
|
.hk-pult .hk-pult-unten { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.hk-pult .hk-reiter-leiste { position: relative; z-index: 1; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; }
|
||||||
|
.hk-pult .hk-zahl { font-size: 13px; line-height: 14px; font-weight: 700; color: #5a4a3a; }
|
||||||
|
|
||||||
|
/* Klemmbrett: das Brett ist ein flexibler Streifen, die Liste scrollt darin */
|
||||||
|
.hk-pult .hk-brett { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.hk-pult .hk-brett-kopf { padding: 6px 4px; border-bottom: 2px solid rgba(90, 60, 176, 0.3); }
|
||||||
|
.hk-pult .hk-brett-titel { margin: 0; font-size: 17px; line-height: 20px; font-weight: 700; color: #4a2fa0; }
|
||||||
|
.hk-pult .hk-brett-unter { margin: 2px 0 0; font-size: 13px; line-height: 16px; color: #5a4a3a; }
|
||||||
|
|
||||||
|
/* Meldungen auf dem Brett (Abschluss, später eintreffendes Ergebnis, Verbindung gestört) und im Brief */
|
||||||
|
.hk-pult .hk-hinweis {
|
||||||
|
margin: 0 4px; padding: 6px 10px; font-size: 14px; line-height: 18px; color: #2b2230; background: #fff7d6;
|
||||||
|
box-shadow: 0 0 0 2px #2b2230, 2px 2px 0 2px rgba(43, 34, 48, 0.22); overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.hk-pult .hk-hinweis:focus-visible { outline: 2px solid #1a1630; box-shadow: 0 0 0 4px #ffd25a; }
|
||||||
|
.hk-pult .hk-hinweis-ok { background: #e3f3d6; color: #1f5a2a; }
|
||||||
|
.hk-pult .hk-hinweis-err { background: #fbe3e0; color: #8e1f17; }
|
||||||
|
.hk-pult .hk-hinweis-warn { background: #fff3cf; color: #6b4a00; }
|
||||||
|
.hk-pult .hk-brief .hk-hinweis { margin: 12px 0 0; }
|
||||||
|
|
||||||
|
/* Laden und Leerzustand */
|
||||||
|
.hk-pult .hk-laden { margin: 8px 4px; color: #5a4a3a; }
|
||||||
|
.hk-pult .hk-leer { margin: 8px 4px; color: #5a4a3a; }
|
||||||
|
.hk-pult .hk-leer-titel { margin: 0; font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.hk-pult .hk-leer-text { margin: 2px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||||
|
.hk-pult .hk-leer-knopf { margin-top: 10px; }
|
||||||
|
|
||||||
|
/* ---------- Umschlag (Knopf in der Liste): Icon links über drei Zeilen, rechts Titel, Art · Alter, Marken ---------- */
|
||||||
|
.hk-pult .hk-liste .hk-umschlag { grid-template-columns: 32px minmax(0, 1fr); }
|
||||||
|
.hk-pult .hk-umschlag-art { grid-area: 1 / 1 / 4 / 2; }
|
||||||
|
.hk-pult .hk-umschlag-titel { grid-column: 2; grid-row: 1; font-size: 15px; line-height: 18px; font-weight: 700; }
|
||||||
|
.hk-pult .hk-umschlag-art-alter { grid-column: 2; grid-row: 2; }
|
||||||
|
.hk-pult .hk-umschlag-marken { grid-column: 2; grid-row: 3; display: flex; align-items: center; flex-wrap: wrap; gap: 2px 10px; min-height: 16px; }
|
||||||
|
.hk-pult .hk-klein-inline { display: inline; }
|
||||||
|
.hk-pult .hk-marke-frist { color: #9a4a10; }
|
||||||
|
.hk-pult .hk-marke-halter { color: #4a2fa0; }
|
||||||
|
.hk-pult .hk-marke-veraltet { color: #8a4a20; }
|
||||||
|
|
||||||
|
/* ---------- Brief: Dunkelfläche mit Hintergrund-Knopf, Papier in der Mitte, Inhalt scrollt ---------- */
|
||||||
|
.hk-pult .hk-brief-ueber { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; }
|
||||||
|
.hk-pult .hk-brief-hinten {
|
||||||
|
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0; background: rgba(6, 7, 18, 0.6); cursor: default;
|
||||||
|
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.hk-pult .hk-brief { position: relative; width: min(640px, 100%); max-height: 100%; display: flex; flex-direction: column; color: #2b2230; outline: none; }
|
||||||
|
.hk-pult .hk-brief-inhalt { overflow: auto; min-height: 0; padding: 6px; scrollbar-width: thin; scrollbar-color: #5a3cb0 #e8dcc0; }
|
||||||
|
.hk-pult .hk-brief-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
|
.hk-pult .hk-brief-titelblock { flex: 1 1 auto; min-width: 0; order: 1; }
|
||||||
|
.hk-pult .hk-brief-zu { order: 2; flex: none; }
|
||||||
|
.hk-pult .hk-brief-art { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #4a2fa0; }
|
||||||
|
.hk-pult .hk-brief-titel { margin: 6px 0 0; font-size: 19px; line-height: 24px; font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.hk-pult .hk-brief-meta { margin-top: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: #5a4a3a; }
|
||||||
|
.hk-pult .hk-link {
|
||||||
|
display: inline-flex; align-items: center; min-height: 24px; margin: 0; padding: 0; border: 0; background: none;
|
||||||
|
font: inherit; color: #4a2fa0; text-decoration: underline; text-underline-offset: 3px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.hk-pult .hk-link:hover { color: #2f1a80; }
|
||||||
|
.hk-pult .hk-link:focus-visible { outline: 2px solid #1a1630; outline-offset: 2px; box-shadow: 0 0 0 4px #ffd25a; }
|
||||||
|
.hk-pult .hk-brief-h { margin: 14px 0 4px; font-size: 13px; line-height: 16px; font-weight: 700; color: #4a2fa0; }
|
||||||
|
.hk-pult .hk-brief-h-aktionen { margin: 16px 0 6px; }
|
||||||
|
.hk-pult .hk-brief-zitat {
|
||||||
|
margin: 0; padding: 8px 10px; background: #f1ead6; box-shadow: inset 4px 0 0 0 #c9a25c;
|
||||||
|
white-space: pre-line; font-size: 14px; line-height: 18px; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.hk-pult .hk-feld-label { display: block; margin-top: 14px; }
|
||||||
|
.hk-pult .hk-feld-titel { display: block; margin-bottom: 4px; font-size: 13px; line-height: 16px; color: #4a2fa0; }
|
||||||
|
.hk-pult .hk-brief-entwurf { min-height: 96px; background: repeating-linear-gradient(#fffdf6 0 17px, rgba(90, 60, 176, 0.16) 17px 18px); }
|
||||||
|
.hk-pult .hk-brief-lesen {
|
||||||
|
margin: 0; padding: 2px 10px 4px; white-space: pre-line; font-size: 15px; line-height: 20px; overflow-wrap: anywhere;
|
||||||
|
background: repeating-linear-gradient(transparent 0 19px, rgba(90, 60, 176, 0.18) 19px 20px);
|
||||||
|
}
|
||||||
|
.hk-pult .hk-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
|
||||||
|
.hk-pult .hk-brief-hinweis { margin: 0; font-size: 13px; color: #5a4a3a; }
|
||||||
|
.hk-pult .hk-brief-hinweis-bau { color: #7a2e3a; }
|
||||||
|
.hk-pult .hk-stempel:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.hk-pult a.hk-stempel { text-decoration: none; }
|
||||||
|
.hk-pult .hk-feld-panel { margin-top: 14px; }
|
||||||
|
.hk-pult .hk-feld-antwort,
|
||||||
|
.hk-pult .hk-feld-einfuegen { padding-top: 6px; border-top: 2px dashed rgba(90, 60, 176, 0.25); }
|
||||||
|
.hk-pult .hk-feld-panel .hk-feld-label { margin-top: 0; }
|
||||||
|
.hk-pult .hk-feld-panel .hk-brief-hinweis { margin: 6px 0 0; }
|
||||||
|
.hk-pult .hk-feld-fuss { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.hk-pult .hk-zaehler { font-size: 13px; line-height: 16px; color: #5a4a3a; }
|
||||||
|
.hk-pult .hk-zaehler-ueber { color: #a8261d; font-weight: 700; }
|
||||||
|
.hk-pult .hk-feld-knoepfe { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
/* ---------- Handy (unter 720 px, Spec E13): Klemmbrett in voller Breite über der abgedunkelten Szene, Brief als Vollbild ---------- */
|
||||||
|
@media (max-width: 719px) {
|
||||||
|
.hk-pult .hk-pult-vignette {
|
||||||
|
background: linear-gradient(rgba(6, 7, 18, 0.55), rgba(6, 7, 18, 0.55)), radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
|
||||||
|
}
|
||||||
|
.hk-pult .hk-pult-ui { left: 8px; right: 8px; top: 8px; bottom: 8px; width: auto; }
|
||||||
|
.hk-pult .hk-reiter-leiste { padding-left: 8px; }
|
||||||
|
/* Kopfzeile in zwei Reihen: Zurück und Zuklappen nebeneinander, darunter das Messingschild; die Klammer entfällt (sie läge auf dem dritten Reiter) */
|
||||||
|
.hk-pult .hk-pult-kopf { gap: 4px 8px; margin-bottom: 2px; }
|
||||||
|
.hk-pult .hk-pult-knopf { order: 1; }
|
||||||
|
.hk-pult .hk-pult-schild { order: 2; flex: 1 1 100%; }
|
||||||
|
.hk-pult .hk-klammer { display: none; }
|
||||||
|
.hk-pult .hk-brief-ueber { padding: 0; }
|
||||||
|
.hk-pult .hk-brief { width: 100%; height: 100%; max-height: 100%; }
|
||||||
|
.hk-pult .hk-feld-fuss { flex-direction: column; align-items: stretch; }
|
||||||
|
.hk-pult .hk-feld-knoepfe { flex-direction: column-reverse; }
|
||||||
|
.hk-pult .hk-feld-knoepfe .hk-stempel { justify-content: center; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import esbuild from 'esbuild'
|
||||||
|
import { fakeReact } from './helpers/fakeReact.mjs'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
// Dein Pult (Hustler-Pixelwelt T9, Spec E11/E13/E14): die reine Logik (src/lib/hustlerPult.js) und die Bauteile PultList, PultSzene, Klemmbrett, Brief im Mini-Läufer
|
||||||
|
// anstelle von React (helpers/fakeReact.mjs). Die Aktionen des Briefs (Antworten, Freigeben, Bauen, Einfügen, Fokus nach Aktionen) prüft workersPultUi.test.mjs;
|
||||||
|
// hier stehen Umschlag-Inhalt je Art und Status, Zahlen an den Reitern, Brief öffnen/schließen/Fokus, Boten nur bei wartenden Einträgen, „Pult zuklappen“, „◂ Hochhaus“,
|
||||||
|
// der Weg zum Interessenten-Panel, die Klassen gegen das CSS und die Regeln der Architektur (kein CSS-/Bild-Import, kein innerHTML, sichere Links).
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||||
|
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8')
|
||||||
|
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||||||
|
|
||||||
|
const L = await loadModule('src/lib/hustlerPult.js')
|
||||||
|
const P = await loadModule('src/lib/workersPult.js')
|
||||||
|
const { PULT_KIND_LABELS, PULT_FILTERS, rowModel } = P
|
||||||
|
|
||||||
|
const NOW = Date.now() // die Bauteile rechnen mit der Uhr des Rechners: Alter und Fristen hängen deshalb an dieser Zeit
|
||||||
|
const iso = (ms) => new Date(ms).toISOString()
|
||||||
|
const ME = { id: 'u1', name: 'Justin' }
|
||||||
|
const EMPLOYEES = [{ userId: 'u2', displayName: 'Kenso' }]
|
||||||
|
const rowOf = (item) => rowModel(item, { me: ME, nowMs: NOW, employees: EMPLOYEES })
|
||||||
|
const entry = (extra = {}) => ({ $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(NOW - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72, email: 'x@verein.example' }, ...extra })
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// Reine Logik
|
||||||
|
|
||||||
|
test('szenePassung: „cover“ wie bindeRoot/passe der Vorlage, unbrauchbare Flächen ergeben null', () => {
|
||||||
|
const at = (w, h) => { const p = L.szenePassung(w, h); return p && [p.s, p.x, p.y, p.transform] }
|
||||||
|
assert.deepEqual(at(1280, 720), [2, 0, 0, 'translate(0.0px, 0.0px) scale(2.0000)'])
|
||||||
|
assert.deepEqual(at(1920, 1080), [3, 0, 0, 'translate(0.0px, 0.0px) scale(3.0000)'])
|
||||||
|
// breiter als hoch: die Szene ist höher als die Fläche, unten bleibt der Raum sichtbar (y = Überstand + 36 s, hier der kleinere von Überstand / 2 und Überstand + 36 s)
|
||||||
|
assert.deepEqual(at(2560, 1080), [4, 0, -216, 'translate(0.0px, -216.0px) scale(4.0000)'])
|
||||||
|
// höher als breit: die Szene ist breiter als die Fläche und rutscht um 15 % des Überstands nach links
|
||||||
|
assert.deepEqual(at(800, 900), [2.5, -120, 0, 'translate(-120.0px, 0.0px) scale(2.5000)'])
|
||||||
|
for (const [w, h] of [[0, 100], [100, 0], [-1, 5], [NaN, 5], [5, Infinity], ['5', 5], [undefined, undefined]]) assert.equal(L.szenePassung(w, h), null, `${w} x ${h}`)
|
||||||
|
assert.deepEqual({ ...L.SZENE }, { w: 640, h: 360 })
|
||||||
|
assert.equal(L.SZENE_START.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('botenLaufen: nur bei kpis.pultOpen > 0 (Zahl), sonst ruht der Raum', () => {
|
||||||
|
assert.equal(L.botenLaufen({ pultOpen: 1 }), true)
|
||||||
|
assert.equal(L.botenLaufen({ pultOpen: 12 }), true)
|
||||||
|
for (const kpis of [undefined, null, {}, { pultOpen: 0 }, { pultOpen: -1 }, { pultOpen: '3' }, { pultOpen: NaN }, { pultOpen: null }, [], 'x']) assert.equal(L.botenLaufen(kpis), false, JSON.stringify(kpis))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zahlen an den Reitern: „Wartet“ aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die übrigen Reiter ohne Zahl', () => {
|
||||||
|
const z = (id, o) => L.reiterZahl(id, o)
|
||||||
|
// Wartet zeigt kpis.pultOpen (auch am offenen Reiter); ohne Zahl vom Dienst am offenen Reiter die Länge der Liste
|
||||||
|
assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 2 }), 5)
|
||||||
|
assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: null }), 5)
|
||||||
|
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 9 }), 5)
|
||||||
|
assert.equal(z('offen', { aktivId: 'veraltet', kpis: {}, geladen: 9 }), null)
|
||||||
|
assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: 4 }), 4)
|
||||||
|
assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: null }), null)
|
||||||
|
// Veraltet, Erledigt: nur wenn offen
|
||||||
|
assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), 3)
|
||||||
|
assert.equal(z('erledigt', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), null)
|
||||||
|
assert.equal(z('veraltet', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 3 }), null)
|
||||||
|
assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: {}, geladen: null }), null)
|
||||||
|
// kaputte Zahlen sind keine Zahlen
|
||||||
|
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: '5' }, geladen: 3 }), null)
|
||||||
|
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: -2 }, geladen: 3 }), null)
|
||||||
|
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 0 }, geladen: 3 }), 0)
|
||||||
|
assert.equal(L.wartetZahl({ pultOpen: 3 }, 'erledigt', null), 3)
|
||||||
|
assert.equal(L.wartetZahl({}, 'offen', 7), 7)
|
||||||
|
assert.equal(L.wartetZahl({}, 'veraltet', 7), null)
|
||||||
|
assert.equal(L.reiterName('Wartet', 1), 'Wartet, 1 Eintrag')
|
||||||
|
assert.equal(L.reiterName('Wartet', 0), 'Wartet, 0 Einträge')
|
||||||
|
assert.equal(L.reiterName('Erledigt', 12), 'Erledigt, 12 Einträge')
|
||||||
|
assert.equal(L.reiterName('Veraltet', null), 'Veraltet')
|
||||||
|
assert.deepEqual(Object.keys(L.REITER_KLASSE), PULT_FILTERS.map((f) => f.id))
|
||||||
|
assert.deepEqual(Object.keys(L.UNTERTITEL), PULT_FILTERS.map((f) => f.id)); assert.deepEqual(Object.keys(L.LEER), PULT_FILTERS.map((f) => f.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artView: jede Pult-Art des Dienstes hat Name und Icon; Namen wie PULT_KIND_LABELS, unbekannte Art bekommt das Fragezeichen', () => {
|
||||||
|
assert.deepEqual(Object.keys(L.ART_ICONS).sort(), Object.keys(PULT_KIND_LABELS).sort())
|
||||||
|
for (const [kind, label] of Object.entries(PULT_KIND_LABELS)) assert.deepEqual({ ...L.artView(kind) }, { label, icon: L.ART_ICONS[kind] }, kind)
|
||||||
|
assert.deepEqual({ ...L.artView('neu_vom_dienst') }, { label: 'neu_vom_dienst', icon: 'art-unsicher' })
|
||||||
|
// jedes Icon gibt es im erzeugten CSS
|
||||||
|
const css = read('src/styles/hustler/pult.css')
|
||||||
|
for (const icon of Object.values(L.ART_ICONS)) assert.ok(css.includes(`.hk-ic-${icon}{`), icon)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('umschlagModell: Inhalt je Art und Status – Titel, Alter, Ort · Score, Frist, veraltet, Halter, Ergebnis-Stempel, zugänglicher Name', () => {
|
||||||
|
const m = (extra) => L.umschlagModell(rowOf(entry(extra)))
|
||||||
|
// offene Plattform-Antwort: Art, Alter, Ort · Score, nichts sonst
|
||||||
|
let u = m()
|
||||||
|
assert.deepEqual({ ...u, fertig: u.fertig }, {
|
||||||
|
id: 'a', artLabel: 'Antwort auf Plattform-Anfrage', icon: 'art-plattform', titel: 'Suche Webdesigner', alter: 'vor 3 Std.', info: '66640 Namborn · Score 72',
|
||||||
|
frist: '', veraltet: false, gehalten: '', fertig: null, label: 'Antwort auf Plattform-Anfrage: Suche Webdesigner, vor 3 Std.',
|
||||||
|
})
|
||||||
|
// jede Art zeigt ihr Icon
|
||||||
|
for (const kind of Object.keys(PULT_KIND_LABELS)) assert.equal(m({ kind }).icon, L.ART_ICONS[kind], kind)
|
||||||
|
// läuft bald ab (< 24 h), veraltet (Status), veraltet durch staleAt
|
||||||
|
u = m({ staleAt: iso(NOW + 5 * 3600e3) })
|
||||||
|
assert.equal(u.frist, 'läuft in 5 Std. ab'); assert.equal(u.veraltet, false); assert.ok(u.label.endsWith('vor 3 Std., läuft in 5 Std. ab'))
|
||||||
|
u = m({ status: 'veraltet' })
|
||||||
|
assert.equal(u.veraltet, true); assert.equal(u.frist, ''); assert.ok(u.label.endsWith(', veraltet'))
|
||||||
|
assert.equal(m({ staleAt: iso(NOW - 1000) }).veraltet, true)
|
||||||
|
assert.equal(m({ staleAt: iso(NOW + 3 * 24 * 3600e3) }).frist, '')
|
||||||
|
// übernommen: von mir „Du kümmerst dich darum.“, von einem Kollegen mit Namen, von Max automatisch, unbekannt „Kollege“
|
||||||
|
u = m({ status: 'in_arbeit', claimedBy: 'mensch:u1' })
|
||||||
|
assert.equal(u.gehalten, 'Du kümmerst dich darum.'); assert.ok(u.label.endsWith(', du kümmerst dich darum'))
|
||||||
|
u = m({ status: 'in_arbeit', claimedBy: 'mensch:u2' })
|
||||||
|
assert.equal(u.gehalten, 'übernommen von Kenso'); assert.ok(u.label.endsWith(', übernommen von Kenso'))
|
||||||
|
assert.equal(m({ status: 'in_arbeit', claimedBy: 'max' }).gehalten, 'übernommen von Max (automatisch)')
|
||||||
|
assert.equal(m({ status: 'in_arbeit', claimedBy: 'mensch:u9' }).gehalten, 'übernommen von Kollege')
|
||||||
|
// bei veraltet hält niemand mehr, auch wenn der frühere Bearbeiter noch drinsteht
|
||||||
|
assert.equal(m({ status: 'veraltet', claimedBy: 'mensch:u2' }).gehalten, '')
|
||||||
|
// abgeschlossen: Ergebnis-Stempel statt Alter ab Anlage (gesendet grün, die übrigen grau)
|
||||||
|
const done = { createdAt: iso(NOW - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(NOW - 2 * 3600e3) }
|
||||||
|
u = m({ ...done, status: 'gesendet' })
|
||||||
|
assert.deepEqual({ ...u.fertig }, { text: 'Gesendet von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-gruen' }); assert.equal(u.alter, '')
|
||||||
|
assert.equal(u.label, 'Antwort auf Plattform-Anfrage: Suche Webdesigner, Gesendet von Kenso · vor 2 Std.')
|
||||||
|
assert.deepEqual({ ...m({ ...done, status: 'verworfen' }).fertig }, { text: 'Verworfen von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
|
||||||
|
assert.deepEqual({ ...m({ ...done, status: 'erledigt', doneBy: 'max' }).fertig }, { text: 'Erledigt von Max (automatisch) · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
|
||||||
|
assert.equal(m({ ...done, status: 'erledigt', doneBy: undefined }).fertig.text, 'Erledigt · vor 2 Std.')
|
||||||
|
// ohne Interessent keine Kurzinfo, ohne Titel nur die Art im Namen, ohne Alter (kaputte Zeit) kein Alter
|
||||||
|
assert.equal(m({ prospect: null }).info, '')
|
||||||
|
assert.equal(m({ title: ' ' }).label, 'Antwort auf Plattform-Anfrage, vor 3 Std.')
|
||||||
|
assert.equal(m({ createdAt: 'kaputt' }).alter, '')
|
||||||
|
assert.equal(L.prospectInfo({ city: 'Worms' }), 'Worms'); assert.equal(L.prospectInfo({ score: 0 }), 'Score 0'); assert.equal(L.prospectInfo(null), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------
|
||||||
|
// Bauteile im Mini-Läufer
|
||||||
|
|
||||||
|
// Was das Pult von außen holt: Feed (je Filter eine Antwort), Mitarbeiterliste, Zwischenablage, das Interessenten-Panel (hält seine Eigenschaften fest)
|
||||||
|
const STUBS = {
|
||||||
|
usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }',
|
||||||
|
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
|
||||||
|
CopyableCredential: 'export async function copyTextChecked() {}',
|
||||||
|
ProspectPanel: "import { jsx } from 'react'\nexport default function ProspectPanel(props) { globalThis.__panel = props; return jsx('div', { 'data-panel': props.prospectId }) }",
|
||||||
|
}
|
||||||
|
const stubs = {
|
||||||
|
name: 'pult-stubs',
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const loadUi = () => esbuild.build({
|
||||||
|
stdin: {
|
||||||
|
contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'\nexport { default as PultSzene } from './src/components/workers/pult/PultSzene.jsx'",
|
||||||
|
resolveDir: ROOT, loader: 'js',
|
||||||
|
},
|
||||||
|
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs],
|
||||||
|
}).then(importBundle)
|
||||||
|
|
||||||
|
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 byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name))
|
||||||
|
const btn = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
|
||||||
|
const classesIn = (tree, into = new Set()) => {
|
||||||
|
for (const n of nodes(tree, (x) => typeof x.tag === 'string')) for (const c of String(n.props.className ?? '').split(' ')) if (c) into.add(c)
|
||||||
|
return into
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeFocus() {
|
||||||
|
const body = { name: 'body' }
|
||||||
|
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
|
||||||
|
const f = { doc, body, found: new Map(), made: new Map(), all: [] }
|
||||||
|
f.el = (name, extra = {}) => ({
|
||||||
|
name, isConnected: true, disabled: false, focusCalls: 0, focus() { this.focusCalls += 1; doc.activeElement = this },
|
||||||
|
querySelector: (selector) => f.found.get(selector) ?? null, querySelectorAll: () => f.all, ...extra,
|
||||||
|
})
|
||||||
|
f.ref = (key) => f.made.get(key)
|
||||||
|
const had = Object.hasOwn(globalThis, 'document')
|
||||||
|
const saved = globalThis.document
|
||||||
|
globalThis.document = doc
|
||||||
|
globalThis.__fakeRef = (tag, props) => {
|
||||||
|
const key = String(props.className ?? tag).split(' ')[0]
|
||||||
|
if (!f.made.has(key)) f.made.set(key, f.el(key))
|
||||||
|
return f.made.get(key)
|
||||||
|
}
|
||||||
|
f.restore = () => {
|
||||||
|
if (had) globalThis.document = saved
|
||||||
|
else delete globalThis.document
|
||||||
|
delete globalThis.__fakeRef
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Lauf des Pults: items gelten für jeden Filter (der Test steuert über die Liste, was sichtbar ist); opts: kpis, onBack, api
|
||||||
|
async function startPult(items, { api = { pultAction: async () => ({}) }, kpis, onBack, feed = {} } = {}) {
|
||||||
|
const { __mount, jsx, PultList } = await loadUi()
|
||||||
|
const timers = new Map()
|
||||||
|
let nextTimer = 1
|
||||||
|
const refreshes = []
|
||||||
|
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: () => {},
|
||||||
|
}
|
||||||
|
globalThis.__pultEmployees = EMPLOYEES
|
||||||
|
globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra })
|
||||||
|
delete globalThis.__panel
|
||||||
|
const mounted = __mount(jsx(PultList, { api, me: ME, kpis, onBack }))
|
||||||
|
const view = {
|
||||||
|
timers, refreshes,
|
||||||
|
tree: () => mounted.tree(),
|
||||||
|
brief: () => nodes(view.tree(), (n) => n.tag === 'section' && hasClass(n, 'hk-brief'))[0] ?? null,
|
||||||
|
envelopes: () => byClass(view.tree(), 'hk-umschlag'),
|
||||||
|
async open(index = 0, currentTarget) {
|
||||||
|
const e = view.envelopes()[index]
|
||||||
|
assert.ok(e, `Umschlag ${index}`)
|
||||||
|
e.props.onClick({ preventDefault() {}, currentTarget })
|
||||||
|
await settle()
|
||||||
|
},
|
||||||
|
async press(node) { node.props.onClick?.({ preventDefault() {} }); await settle() },
|
||||||
|
async briefKey(key, extra = {}) {
|
||||||
|
const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true }, ...extra }
|
||||||
|
view.brief().props.onKeyDown(event)
|
||||||
|
await settle()
|
||||||
|
return event
|
||||||
|
},
|
||||||
|
close: () => view.press(nodes(view.brief(), (n) => n.props['aria-label'] === 'Schließen')[0]),
|
||||||
|
tab: async (label) => { await view.press(nodes(byClass(view.tree(), 'hk-reiter-leiste')[0], (n) => n.tag === 'button' && textOf(n).startsWith(label))[0]) },
|
||||||
|
end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees; delete globalThis.__panel },
|
||||||
|
}
|
||||||
|
await settle()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLE_ARTEN = Object.keys(PULT_KIND_LABELS).map((kind, i) => entry({ $id: `k${i}`, kind, title: `Titel ${kind}`, draft: 'Entwurf', originalText: 'Anfrage des Kunden', sourceUrl: kind === 'vorschau_freigabe' ? 'https://entwurf.project.webklar.com/AAA/' : 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospectId: 'p1' }))
|
||||||
|
|
||||||
|
test('Pult: die Liste zeigt je Eintrag einen Umschlag mit Art-Icon, Titel, Art · Alter, Ort · Score und den Marken des Zustands', async () => {
|
||||||
|
const items = [
|
||||||
|
entry({ $id: 'a' }),
|
||||||
|
entry({ $id: 'b', kind: 'mail_antwort', title: 'Zweite', status: 'veraltet' }),
|
||||||
|
entry({ $id: 'c', kind: 'unsicher', title: 'Dritte', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 5 * 3600e3) }),
|
||||||
|
entry({ $id: 'd', kind: 'preisfrage', title: 'Vierte', status: 'in_arbeit', claimedBy: 'mensch:u1' }),
|
||||||
|
entry({ $id: 'e', kind: 'beschwerde', title: 'Fünfte', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 2 * 3600e3) }),
|
||||||
|
]
|
||||||
|
const ui = await startPult(items)
|
||||||
|
try {
|
||||||
|
const rows = ui.envelopes()
|
||||||
|
assert.equal(rows.length, 5)
|
||||||
|
const sig = (n) => ({ icon: byClass(n, 'hk-ic')[0].props.className, titel: textOf(byClass(n, 'hk-umschlag-titel')[0]), artAlter: textOf(byClass(n, 'hk-umschlag-art-alter')[0]), marken: byClass(n, 'hk-marke').map(textOf), stempel: byClass(n, 'hk-abdruck').map(textOf), info: byClass(n, 'hk-klein-inline').map(textOf) })
|
||||||
|
assert.deepEqual(sig(rows[0]), { icon: 'hk-ic hk-ic-art-plattform hk-umschlag-art', titel: 'Suche Webdesigner', artAlter: 'Antwort auf Plattform-Anfrage · vor 3 Std.', marken: [], stempel: [], info: ['66640 Namborn · Score 72'] })
|
||||||
|
assert.deepEqual([sig(rows[1]).icon, sig(rows[1]).marken], ['hk-ic hk-ic-art-mail hk-umschlag-art', ['veraltet']])
|
||||||
|
assert.deepEqual([sig(rows[2]).icon, sig(rows[2]).marken], ['hk-ic hk-ic-art-unsicher hk-umschlag-art', ['läuft in 5 Std. ab', 'übernommen von Kenso']])
|
||||||
|
assert.deepEqual([sig(rows[3]).icon, sig(rows[3]).marken], ['hk-ic hk-ic-art-preisfrage hk-umschlag-art', ['Du kümmerst dich darum.']])
|
||||||
|
assert.deepEqual([sig(rows[4]).icon, sig(rows[4]).marken, sig(rows[4]).stempel, sig(rows[4]).artAlter], ['hk-ic hk-ic-art-beschwerde hk-umschlag-art', [], ['Gesendet von Kenso · vor 2 Std.'], 'Beschwerde'])
|
||||||
|
// Knopf mit Namen, Fenster-Hinweis und ohne eigenen Fokus-Trick: ein Klick öffnet, sonst nichts
|
||||||
|
assert.ok(rows.every((r) => r.tag === 'button' && r.props.type === 'button' && r.props['aria-haspopup'] === 'dialog'))
|
||||||
|
assert.equal(rows[2].props['aria-label'], 'Unsicher – bitte prüfen: Dritte, vor 3 Std., läuft in 5 Std. ab, übernommen von Kenso')
|
||||||
|
assert.equal(ui.brief(), null)
|
||||||
|
// „von <Hustler>“ gibt es nicht: ein Pult-Eintrag nennt keinen Hustler
|
||||||
|
assert.ok(!ui.tree() || !textOf(ui.tree()).includes('Gebracht von'))
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: Zahlen an den Reitern und im Kopf – Wartet aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die anderen ohne Zahl', async () => {
|
||||||
|
const wartet = [entry({ $id: 'a' }), entry({ $id: 'b' })]
|
||||||
|
const byStatus = { 'offen,in_arbeit': wartet, veraltet: [entry({ $id: 'v', status: 'veraltet' })], 'gesendet,verworfen,erledigt': [entry({ $id: 'x', status: 'gesendet' }), entry({ $id: 'y', status: 'gesendet' }), entry({ $id: 'z', status: 'gesendet' })] }
|
||||||
|
const ui = await startPult([], { kpis: { pultOpen: 5 }, feed: { byStatus } })
|
||||||
|
try {
|
||||||
|
const tabs = () => byClass(ui.tree(), 'hk-reiter').map((t) => ({ text: textOf(t), name: t.props['aria-label'], an: t.props['aria-pressed'] }))
|
||||||
|
const kopf = () => textOf(byClass(ui.tree(), 'hk-pult-zahl')[0] ?? { children: [] })
|
||||||
|
// Wartet ist offen: die Liste hat 2 Einträge, Reiter und Kopf zeigen die Zahl des Dienstes (wie der Briefkasten im Haus)
|
||||||
|
assert.deepEqual(tabs(), [
|
||||||
|
{ text: 'Wartet5', name: 'Wartet, 5 Einträge', an: true }, { text: 'Veraltet', name: undefined, an: false }, { text: 'Erledigt', name: undefined, an: false },
|
||||||
|
])
|
||||||
|
assert.equal(kopf(), 'Wartet auf dich:5')
|
||||||
|
await ui.tab('Veraltet')
|
||||||
|
assert.deepEqual(tabs(), [
|
||||||
|
{ text: 'Wartet5', name: 'Wartet, 5 Einträge', an: false }, { text: 'Veraltet1', name: 'Veraltet, 1 Eintrag', an: true }, { text: 'Erledigt', name: undefined, an: false },
|
||||||
|
])
|
||||||
|
await ui.tab('Erledigt')
|
||||||
|
assert.deepEqual(tabs().map((t) => t.text), ['Wartet5', 'Veraltet', 'Erledigt3'])
|
||||||
|
assert.equal(kopf(), 'Wartet auf dich:5')
|
||||||
|
} finally { ui.end() }
|
||||||
|
// ohne Zahl vom Dienst: nur der offene Reiter „Wartet“ kennt seine Zahl (die Länge der Liste), im Kopf ebenso; in anderen Reitern fehlt sie überall
|
||||||
|
const ohne = await startPult([], { kpis: undefined, feed: { byStatus } })
|
||||||
|
try {
|
||||||
|
assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet2', 'Veraltet', 'Erledigt'])
|
||||||
|
assert.equal(textOf(byClass(ohne.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:2')
|
||||||
|
await ohne.tab('Veraltet')
|
||||||
|
assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet1', 'Erledigt'])
|
||||||
|
assert.equal(byClass(ohne.tree(), 'hk-pult-zahl').length, 0)
|
||||||
|
} finally { ohne.end() }
|
||||||
|
// während des Ladens und bei einem Fehler ohne Einträge steht keine erfundene „0“
|
||||||
|
const laden = await startPult([], { kpis: {}, feed: { extra: { loading: true } } })
|
||||||
|
try {
|
||||||
|
assert.deepEqual(byClass(laden.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt']); assert.equal(byClass(laden.tree(), 'hk-pult-zahl').length, 0)
|
||||||
|
assert.ok(textOf(laden.tree()).includes('Wird geladen …'))
|
||||||
|
} finally { laden.end() }
|
||||||
|
const kaputt = await startPult([], { kpis: {}, feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
|
||||||
|
try {
|
||||||
|
assert.deepEqual(byClass(kaputt.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt'])
|
||||||
|
assert.ok(textOf(kaputt.tree()).includes('Das Pult ist gerade nicht erreichbar'))
|
||||||
|
assert.equal(btn(kaputt.tree(), 'Neu laden').length, 1)
|
||||||
|
await kaputt.press(btn(kaputt.tree(), 'Neu laden')[0]); assert.equal(kaputt.refreshes.length, 1)
|
||||||
|
} finally { kaputt.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: Untertitel und Leerzustand je Reiter wie in der Vorlage', async () => {
|
||||||
|
const ui = await startPult([], { kpis: { pultOpen: 0 } })
|
||||||
|
try {
|
||||||
|
const sub = () => textOf(byClass(ui.tree(), 'hk-brett-unter')[0])
|
||||||
|
const leer = () => byClass(ui.tree(), 'hk-leer').map(textOf)
|
||||||
|
assert.equal(sub(), 'Hier wartet alles, was ein Mensch freigeben muss.'); assert.deepEqual(leer(), ['Nichts wartet auf dichNeue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'])
|
||||||
|
await ui.tab('Veraltet')
|
||||||
|
assert.equal(sub(), 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.'); assert.deepEqual(leer(), ['Nichts ist veraltetEinträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.'])
|
||||||
|
await ui.tab('Erledigt')
|
||||||
|
assert.equal(sub(), 'Was du oder das Team schon abgeschlossen habt.'); assert.deepEqual(leer(), ['Noch nichts erledigtGesendete, verworfene und erledigte Einträge stehen hier.'])
|
||||||
|
// der Kopf zeigt bei pultOpen 0 die „0“ (eine Zahl vom Dienst), die Reiter-Leiste ist eine Gruppe mit Namen
|
||||||
|
assert.equal(textOf(byClass(ui.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:0')
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-reiter-leiste')[0].props.role, 'group')
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: die Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur bei kpis.pultOpen > 0, sonst steht die Ruhe-Klasse der Vorlage am Rahmen', async () => {
|
||||||
|
const klasse = async (kpis) => {
|
||||||
|
const ui = await startPult([], { kpis })
|
||||||
|
try {
|
||||||
|
const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
|
||||||
|
assert.equal(szene.props['aria-hidden'], 'true')
|
||||||
|
return hasClass(szene, 'hk-pult-ruhe')
|
||||||
|
} finally { ui.end() }
|
||||||
|
}
|
||||||
|
assert.equal(await klasse({ pultOpen: 3 }), false)
|
||||||
|
assert.equal(await klasse({ pultOpen: 1 }), false)
|
||||||
|
for (const kpis of [undefined, {}, { pultOpen: 0 }, { pultOpen: '2' }]) assert.equal(await klasse(kpis), true, JSON.stringify(kpis))
|
||||||
|
// die Ruhe-Regeln der Vorlage hängen an .hk-pult-ruhe unterhalb der Wurzel .hk-pult, die Boten und der Aufzug darunter
|
||||||
|
const css = read('src/styles/hustler/pult.css')
|
||||||
|
assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-boten-weg')); assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-sz-briefkasten'))
|
||||||
|
// die Szene steckt innerhalb der Wurzel (PultList) und enthält Boten und Fähnchen
|
||||||
|
const ui = await startPult([], { kpis: { pultOpen: 1 } })
|
||||||
|
try {
|
||||||
|
assert.ok(hasClass(ui.tree(), 'hk-pult'))
|
||||||
|
const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
|
||||||
|
for (const c of ['hk-boten-weg', 'hk-boten-bild', 'hk-sz-tuer', 'hk-sz-briefkasten', 'hk-sz-sekunde', 'hk-sz-schnee', 'hk-sz-kette0', 'hk-sz-lmond', 'hk-sz-monitor']) assert.ok(byClass(szene, c).length >= 1, c)
|
||||||
|
assert.match(byClass(szene, 'hk-pult-buehne')[0].props.style.transform, /^translate\(.*\) scale\(.*\)$/)
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PultSzene: passt sich der Fläche an (ResizeObserver, „cover“) und räumt den Beobachter auf', async () => {
|
||||||
|
const { __mount, jsx, PultSzene } = await loadUi()
|
||||||
|
const size = { width: 1920, height: 1080 }
|
||||||
|
const observed = []
|
||||||
|
const disconnected = []
|
||||||
|
const callbacks = []
|
||||||
|
globalThis.ResizeObserver = class { constructor(cb) { callbacks.push(cb) } observe(el) { observed.push(el) } disconnect() { disconnected.push(true) } }
|
||||||
|
globalThis.__fakeRef = () => ({ getBoundingClientRect: () => ({ ...size }) })
|
||||||
|
try {
|
||||||
|
const mounted = __mount(jsx(PultSzene, { laeuft: true }))
|
||||||
|
await settle()
|
||||||
|
const stage = () => nodes(mounted.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0]
|
||||||
|
assert.equal(stage().props.style.transform, 'translate(0.0px, 0.0px) scale(3.0000)')
|
||||||
|
assert.equal(observed.length, 1)
|
||||||
|
size.width = 2560; size.height = 1080
|
||||||
|
callbacks[0](); await settle()
|
||||||
|
assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)')
|
||||||
|
size.width = 0; size.height = 0
|
||||||
|
callbacks[0](); await settle()
|
||||||
|
assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)') // keine Fläche: das letzte Maß bleibt
|
||||||
|
mounted.unmount()
|
||||||
|
assert.equal(disconnected.length, 1)
|
||||||
|
} finally { delete globalThis.ResizeObserver; delete globalThis.__fakeRef }
|
||||||
|
// ohne ResizeObserver wird einmal gemessen; ohne Rahmen (Server, Node) passiert nichts
|
||||||
|
const einmal = __mount(jsx(PultSzene, {}))
|
||||||
|
await settle()
|
||||||
|
assert.equal(nodes(einmal.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0].props.style.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
|
||||||
|
einmal.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: „Pult zuklappen“ blendet Reiter und Klemmbrett aus (die Szene bleibt), „Pult aufklappen“ bringt sie zurück', async () => {
|
||||||
|
const ui = await startPult([entry()], { kpis: { pultOpen: 1 } })
|
||||||
|
try {
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 1)
|
||||||
|
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
|
||||||
|
await ui.press(btn(ui.tree(), 'Pult zuklappen')[0])
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 0); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 0); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 0)
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-pult-szene').length, 1) // die Szene bleibt ganz zu sehen
|
||||||
|
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 0); assert.equal(btn(ui.tree(), 'Pult aufklappen').length, 1)
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-pult-zahl').length, 1) // Kopf mit Zahl bleibt
|
||||||
|
await ui.press(btn(ui.tree(), 'Pult aufklappen')[0])
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 1)
|
||||||
|
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: „◂ Hochhaus“ ruft onBack; ohne onBack passiert beim Klick nichts', async () => {
|
||||||
|
const calls = []
|
||||||
|
const ui = await startPult([], { onBack: () => calls.push('zurück') })
|
||||||
|
try {
|
||||||
|
const back = btn(ui.tree(), 'Hochhaus')
|
||||||
|
assert.equal(back.length, 1); assert.equal(back[0].tag, 'button'); assert.equal(back[0].props.type, 'button')
|
||||||
|
assert.ok(byClass(back[0], 'hk-ic-zurueck').length === 1 && byClass(back[0], 'hk-ic-zurueck')[0].props['aria-hidden'] === 'true')
|
||||||
|
await ui.press(back[0]); await ui.press(back[0])
|
||||||
|
assert.deepEqual(calls, ['zurück', 'zurück'])
|
||||||
|
} finally { ui.end() }
|
||||||
|
const stumm = await startPult([])
|
||||||
|
try { await stumm.press(btn(stumm.tree(), 'Hochhaus')[0]) } finally { stumm.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Brief: öffnen, schließen, Fokus ----
|
||||||
|
|
||||||
|
test('Pult: ein Klick auf den Umschlag öffnet den Brief als Dialog (role, aria-modal, Name), der Fokus geht auf den ersten Knopf „Schließen“', async () => {
|
||||||
|
const f = fakeFocus()
|
||||||
|
const ui = await startPult([entry({ title: 'Suche Webdesigner' })])
|
||||||
|
try {
|
||||||
|
const umschlag = f.el('umschlag')
|
||||||
|
f.doc.activeElement = umschlag
|
||||||
|
await ui.open(0, umschlag)
|
||||||
|
const brief = ui.brief()
|
||||||
|
assert.ok(brief)
|
||||||
|
assert.deepEqual([brief.props.role, brief.props['aria-modal'], brief.props['aria-label'], brief.props.tabIndex], ['dialog', 'true', 'Antwort auf Plattform-Anfrage: Suche Webdesigner', -1])
|
||||||
|
// der erste Knopf im Brief ist „Schließen“ und hat den Fokus
|
||||||
|
const buttons = nodes(brief, (n) => n.tag === 'button' || n.tag === 'a')
|
||||||
|
assert.equal(buttons[0].props['aria-label'], 'Schließen')
|
||||||
|
assert.deepEqual([f.ref('hk-knopf').focusCalls, f.doc.activeElement], [1, f.ref('hk-knopf')])
|
||||||
|
// Hintergrund-Knopf (nicht in der Tab-Reihenfolge) schließt
|
||||||
|
const hinten = byClass(ui.tree(), 'hk-brief-hinten')[0]
|
||||||
|
assert.deepEqual([hinten.tag, hinten.props.tabIndex, hinten.props['aria-label']], ['button', -1, 'Brief schließen'])
|
||||||
|
// Überschrift, Art, Original und Entwurf stehen im Brief
|
||||||
|
assert.equal(textOf(byClass(brief, 'hk-brief-titel')[0]), 'Suche Webdesigner')
|
||||||
|
assert.equal(byClass(brief, 'hk-brief-titel')[0].tag, 'h2')
|
||||||
|
} finally { ui.end(); f.restore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: der Brief schließt mit „Schließen“, Hintergrund und Esc; der Fokus geht an den Umschlag zurück', async () => {
|
||||||
|
for (const weg of ['knopf', 'hintergrund', 'esc']) {
|
||||||
|
const f = fakeFocus()
|
||||||
|
const ui = await startPult([entry()])
|
||||||
|
try {
|
||||||
|
const umschlag = f.el('umschlag')
|
||||||
|
await ui.open(0, umschlag)
|
||||||
|
assert.ok(ui.brief(), weg)
|
||||||
|
if (weg === 'knopf') await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
|
||||||
|
if (weg === 'hintergrund') await ui.press(byClass(ui.tree(), 'hk-brief-hinten')[0])
|
||||||
|
if (weg === 'esc') { const event = await ui.briefKey('Escape'); assert.ok(event.defaultPrevented && event.stopped) }
|
||||||
|
assert.equal(ui.brief(), null, weg)
|
||||||
|
assert.deepEqual([umschlag.focusCalls, f.doc.activeElement], [1, umschlag], weg)
|
||||||
|
// eine andere Taste schließt nicht
|
||||||
|
} finally { ui.end(); f.restore() }
|
||||||
|
}
|
||||||
|
const ui = await startPult([entry()])
|
||||||
|
try {
|
||||||
|
await ui.open(0)
|
||||||
|
await ui.briefKey('a'); await ui.briefKey('Enter')
|
||||||
|
assert.ok(ui.brief())
|
||||||
|
} finally { ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: Esc schließt erst ein offenes Feld, dann den Brief; Esc bei laufender Anfrage lässt das Feld stehen', async () => {
|
||||||
|
const f = fakeFocus()
|
||||||
|
let finish
|
||||||
|
const api = { pultAction: () => new Promise((resolve) => { finish = resolve }) }
|
||||||
|
const ui = await startPult([entry({ kind: 'unsicher', draft: 'Entwurf' })], { api })
|
||||||
|
try {
|
||||||
|
const umschlag = f.el('umschlag')
|
||||||
|
const opener = f.el('opener')
|
||||||
|
f.found.set('[data-opens="reply"]', opener)
|
||||||
|
await ui.open(0, umschlag)
|
||||||
|
await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
|
||||||
|
assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1)
|
||||||
|
let event = await ui.briefKey('Escape') // der Fokus liegt außerhalb des Textfelds: der Brief schließt das Feld
|
||||||
|
assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 0); assert.ok(ui.brief()); assert.equal(opener.focusCalls, 1)
|
||||||
|
event = await ui.briefKey('Escape')
|
||||||
|
assert.equal(ui.brief(), null); assert.equal(umschlag.focusCalls, 1)
|
||||||
|
// während einer Anfrage: Feld bleibt (nur der Brief hat Esc behandelt)
|
||||||
|
await ui.open(0, umschlag)
|
||||||
|
await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
|
||||||
|
await ui.press(btn(ui.brief(), 'Jetzt senden')[0])
|
||||||
|
event = await ui.briefKey('Escape')
|
||||||
|
assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1); assert.ok(ui.brief())
|
||||||
|
finish({}); await settle()
|
||||||
|
} finally { ui.end(); f.restore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: Tab bleibt im Brief (vom letzten Knopf zum ersten, Umschalt+Tab vom ersten oder vom Brief selbst zum letzten)', async () => {
|
||||||
|
const f = fakeFocus()
|
||||||
|
const ui = await startPult([entry()])
|
||||||
|
try {
|
||||||
|
const erster = f.el('erster'); const mitte = f.el('mitte'); const letzter = f.el('letzter'); const gesperrt = f.el('gesperrt', { disabled: true })
|
||||||
|
f.all = [erster, mitte, gesperrt, letzter]
|
||||||
|
await ui.open(0)
|
||||||
|
// Mitte: Tab geht seinen normalen Weg
|
||||||
|
f.doc.activeElement = mitte
|
||||||
|
let event = await ui.briefKey('Tab'); assert.equal(event.defaultPrevented, false)
|
||||||
|
event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
|
||||||
|
// Ende -> Anfang
|
||||||
|
f.doc.activeElement = letzter
|
||||||
|
event = await ui.briefKey('Tab'); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, erster)
|
||||||
|
// Anfang (und der Brief selbst) mit Umschalt -> Ende; gesperrte Knöpfe zählen nicht
|
||||||
|
event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
|
||||||
|
f.doc.activeElement = f.ref('hk-brief')
|
||||||
|
event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
|
||||||
|
// Umschalt+Tab vom Ende: normal
|
||||||
|
event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
|
||||||
|
} finally { ui.end(); f.restore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: nach einem Abschluss schließt der Brief, die Meldung erscheint (role status) und nimmt den Fokus; der Umschlag bekommt ihn nicht', async () => {
|
||||||
|
const f = fakeFocus()
|
||||||
|
const ui = await startPult([entry({ kind: 'unsicher', title: 'Unklare Antwort', draft: '' })])
|
||||||
|
try {
|
||||||
|
const umschlag = f.el('umschlag')
|
||||||
|
await ui.open(0, umschlag)
|
||||||
|
const erledigt = f.el('erledigt-knopf', { disabled: true })
|
||||||
|
f.doc.activeElement = erledigt
|
||||||
|
await ui.press(btn(ui.brief(), 'Erledigt')[0])
|
||||||
|
assert.equal(ui.brief(), null)
|
||||||
|
const meldung = byClass(ui.tree(), 'hk-hinweis')[0]
|
||||||
|
assert.deepEqual([meldung.props.role, meldung.props.tabIndex, textOf(meldung)], ['status', -1, '„Unklare Antwort“ erledigt.'])
|
||||||
|
assert.deepEqual([f.ref('hk-hinweis').focusCalls, f.doc.activeElement, umschlag.focusCalls], [1, f.ref('hk-hinweis'), 0])
|
||||||
|
// ein Filterwechsel räumt die Meldung weg
|
||||||
|
await ui.tab('Veraltet')
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-hinweis').length, 0)
|
||||||
|
} finally { ui.end(); f.restore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: verschwindet der Eintrag aus der Liste (ein Kollege war schneller), schließt sein offener Brief', async () => {
|
||||||
|
const f = fakeFocus()
|
||||||
|
const items = [entry({ $id: 'a', title: 'Eins' }), entry({ $id: 'b', title: 'Zwei' })]
|
||||||
|
const ui = await startPult(items)
|
||||||
|
try {
|
||||||
|
const umschlag = f.el('umschlag')
|
||||||
|
await ui.open(1, umschlag)
|
||||||
|
assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
|
||||||
|
items.splice(1, 1) // der nächste Abruf des Feeds liefert die kürzere Liste (hier: beim nächsten Zeichnen, ausgelöst durch einen Reiterwechsel)
|
||||||
|
await ui.tab('Veraltet')
|
||||||
|
assert.equal(ui.brief(), null)
|
||||||
|
assert.equal(ui.envelopes().length, 1)
|
||||||
|
} finally { ui.end(); f.restore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: ein Ergebnis, das nach dem Schließen des Briefs eintrifft, erscheint in der Liste (Fehler als Alarm); ein Abschluss danach schließt keinen anderen Brief', async () => {
|
||||||
|
const f = fakeFocus()
|
||||||
|
let reject
|
||||||
|
let finish
|
||||||
|
const api = { pultAction: (id, action) => (action === 'discard' ? new Promise((resolve) => { finish = resolve }) : new Promise((_, no) => { reject = no })) }
|
||||||
|
const items = [entry({ $id: 'a', title: 'Eins', kind: 'unsicher', prospectId: 'p1' }), entry({ $id: 'b', title: 'Zwei', kind: 'unsicher', prospectId: 'p1' })]
|
||||||
|
const ui = await startPult(items, { api })
|
||||||
|
try {
|
||||||
|
// 1. Fehler nach dem Schließen: „Kann gesendet sein“ geht nicht verloren
|
||||||
|
await ui.open(0, f.el('u0'))
|
||||||
|
await ui.press(btn(ui.brief(), 'Vorschau bauen')[0]); await ui.press(btn(ui.brief(), 'Wirklich bauen?')[0])
|
||||||
|
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
|
||||||
|
assert.equal(ui.brief(), null)
|
||||||
|
reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
|
||||||
|
const alarm = byClass(ui.tree(), 'hk-hinweis-err')
|
||||||
|
assert.deepEqual(alarm.map((n) => [n.props.role, textOf(n)]), [['alert', 'Kann gesendet sein – Pult neu laden.']])
|
||||||
|
// ein neuer Brief räumt die späte Meldung weg
|
||||||
|
await ui.open(1)
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-hinweis-err').length, 0)
|
||||||
|
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
|
||||||
|
// 2. Abschluss, nachdem ein anderer Brief offen ist: die Meldung kommt, der andere Brief bleibt, kein Fokus-Raub
|
||||||
|
await ui.open(0, f.el('u0b'))
|
||||||
|
await ui.press(btn(ui.brief(), 'Verwerfen')[0]); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0])
|
||||||
|
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
|
||||||
|
await ui.open(1, f.el('u1'))
|
||||||
|
const vorher = f.doc.activeElement
|
||||||
|
finish({}); await settle()
|
||||||
|
assert.ok(ui.brief()); assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
|
||||||
|
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-ok').map(textOf), ['„Eins“ verworfen.'])
|
||||||
|
assert.equal(f.doc.activeElement, vorher)
|
||||||
|
} finally { ui.end(); f.restore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: „Interessent“ im Brief öffnet das Interessenten-Panel in der WOMS-Hülle, Schließen räumt es weg und gibt den Brief frei', async () => {
|
||||||
|
const ui = await startPult([entry({ prospect: { $id: 'p42', name: 'Verein' } })])
|
||||||
|
try {
|
||||||
|
await ui.open()
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
|
||||||
|
const knopf = btn(ui.brief(), 'Interessent')
|
||||||
|
assert.equal(knopf.length, 1); assert.equal(knopf[0].props['aria-label'], 'Interessent zu Suche Webdesigner')
|
||||||
|
await ui.press(knopf[0])
|
||||||
|
const huelle = byClass(ui.tree(), 'hk-woms')
|
||||||
|
assert.equal(huelle.length, 1)
|
||||||
|
assert.deepEqual(nodes(huelle[0], (n) => n.props['data-panel'] !== undefined).map((n) => n.props['data-panel']), ['p42'])
|
||||||
|
assert.equal(globalThis.__panel.prospectId, 'p42'); assert.ok(ui.brief()) // der Brief bleibt darunter offen
|
||||||
|
globalThis.__panel.onClose(); await settle()
|
||||||
|
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0); assert.ok(ui.brief())
|
||||||
|
} finally { ui.end() }
|
||||||
|
// ohne Interessent gibt es den Knopf nicht
|
||||||
|
const ohne = await startPult([entry({ prospect: null })])
|
||||||
|
try { await ohne.open(); assert.equal(btn(ohne.brief(), 'Interessent').length, 0) } finally { ohne.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Klassen gegen das CSS, Architektur-Regeln ----
|
||||||
|
|
||||||
|
const CSS_DATEIEN = ['pult.css', 'hustler-ui.css', 'hustler-pult-ui.css']
|
||||||
|
const definiert = () => {
|
||||||
|
const out = new Set()
|
||||||
|
for (const f of CSS_DATEIEN) for (const m of read('src/styles/hustler/' + f).matchAll(/\.(hk-[a-z0-9-]+)/g)) out.add(m[1])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Pult: jede gerenderte hk-Klasse steht in pult.css, hustler-ui.css oder hustler-pult-ui.css (Liste, Szene, Brief in allen Arten, Felder, Rückfragen, Meldungen)', async () => {
|
||||||
|
const css = definiert()
|
||||||
|
const seen = new Set()
|
||||||
|
const jeder = [...ALLE_ARTEN,
|
||||||
|
entry({ $id: 'v', status: 'veraltet', staleAt: iso(NOW - 1000) }), entry({ $id: 'h', status: 'in_arbeit', claimedBy: 'mensch:u1' }), entry({ $id: 's', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 3600e3) }),
|
||||||
|
entry({ $id: 'g', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 3600e3) }), entry({ $id: 'x', status: 'verworfen', doneBy: 'max', doneAt: iso(Date.now() - 3600e3) }),
|
||||||
|
]
|
||||||
|
const api = { pultAction: async () => { throw Object.assign(new Error('Fehler des Dienstes.'), { status: 409 }) } }
|
||||||
|
const ui = await startPult(jeder, { api, kpis: { pultOpen: 3 }, onBack() {} })
|
||||||
|
try {
|
||||||
|
classesIn(ui.tree(), seen)
|
||||||
|
for (let i = 0; i < jeder.length; i += 1) {
|
||||||
|
await ui.open(i)
|
||||||
|
classesIn(ui.tree(), seen)
|
||||||
|
// jedes Feld und jede Rückfrage einmal aufklappen, dann Fehler und Erfolg zeigen
|
||||||
|
for (const label of ['Als Mensch antworten', 'Antwort des Kunden einfügen']) {
|
||||||
|
const k = btn(ui.brief(), label)[0]
|
||||||
|
if (k) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Abbrechen')[0]) }
|
||||||
|
}
|
||||||
|
for (const label of ['Verwerfen', 'Vorschau bauen', 'Freigeben & senden']) {
|
||||||
|
const k = btn(ui.brief(), label)[0]
|
||||||
|
if (k && !k.props.disabled) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0] ?? btn(ui.brief(), 'Wirklich bauen?')[0] ?? btn(ui.brief(), 'Wirklich freigeben?')[0]); classesIn(ui.tree(), seen) }
|
||||||
|
}
|
||||||
|
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
|
||||||
|
}
|
||||||
|
// Meldung nach Abschluss, später Ergebnis, Panel
|
||||||
|
classesIn(ui.tree(), seen)
|
||||||
|
} finally { ui.end() }
|
||||||
|
for (const [name, extra] of [['laden', { loading: true }], ['fehler', { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' }]]) {
|
||||||
|
const z = await startPult([], { feed: { extra } })
|
||||||
|
try { classesIn(z.tree(), seen) } finally { z.end() }
|
||||||
|
assert.ok(seen.size > 0, name)
|
||||||
|
}
|
||||||
|
const mitFehler = await startPult(jeder.slice(0, 2), { feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
|
||||||
|
try { classesIn(mitFehler.tree(), seen) } finally { mitFehler.end() }
|
||||||
|
const ohneBoten = await startPult([], { kpis: { pultOpen: 0 } })
|
||||||
|
try { classesIn(ohneBoten.tree(), seen) } finally { ohneBoten.end() }
|
||||||
|
const zu = await startPult([entry({ prospect: { $id: 'p42' } })])
|
||||||
|
try {
|
||||||
|
await zu.open(); await zu.press(btn(zu.brief(), 'Interessent')[0]); classesIn(zu.tree(), seen)
|
||||||
|
await zu.press(btn(zu.tree(), 'Pult zuklappen')[0]); classesIn(zu.tree(), seen)
|
||||||
|
} finally { zu.end() }
|
||||||
|
|
||||||
|
const fehlt = [...seen].filter((c) => c.startsWith('hk-') && !css.has(c))
|
||||||
|
assert.deepEqual(fehlt, [])
|
||||||
|
// gesehen sind die wichtigen Klassen wirklich (der Test prüft nicht ins Leere)
|
||||||
|
for (const c of ['hk-pult', 'hk-pult-szene', 'hk-umschlag', 'hk-brief', 'hk-brief-ueber', 'hk-stempel', 'hk-stempel-frage', 'hk-stempel-gruen', 'hk-stempel-rot', 'hk-stempel-haupt', 'hk-feld-panel', 'hk-feld-antwort', 'hk-feld-einfuegen',
|
||||||
|
'hk-hinweis-err', 'hk-hinweis-warn', 'hk-leer', 'hk-laden', 'hk-reiter-an', 'hk-abdruck', 'hk-marke-frist', 'hk-marke-halter', 'hk-marke-veraltet', 'hk-woms', 'hk-brief-zitat', 'hk-brief-lesen', 'hk-link', 'hk-zaehler',
|
||||||
|
'hk-ic-art-plattform', 'hk-ic-art-mail', 'hk-ic-art-vorschau', 'hk-ic-art-unsicher', 'hk-ic-art-beschwerde', 'hk-ic-art-preisfrage', 'hk-ic-art-mensch', 'hk-ic-art-bau-kaputt', 'hk-ic-bauen', 'hk-ic-verwerfen']) {
|
||||||
|
assert.ok(seen.has(c), `nie gerendert: ${c}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: Quelltext – kein CSS- oder Bild-Import, kein innerHTML, Links nur mit rel, jeder Knopf mit type, Bilder und Dekoration aria-hidden', () => {
|
||||||
|
const dateien = ['src/components/workers/PultList.jsx', 'src/components/workers/pult/Brief.jsx', 'src/components/workers/pult/Klemmbrett.jsx', 'src/components/workers/pult/Marken.jsx', 'src/components/workers/pult/PultSzene.jsx', 'src/lib/hustlerPult.js']
|
||||||
|
for (const f of dateien) {
|
||||||
|
const s = read(f)
|
||||||
|
assert.doesNotMatch(s, /import\s[^\n]*\.(css|png|svg|jpe?g|gif|webp|woff2?)['"]/, `${f}: Import von CSS oder Bild`)
|
||||||
|
assert.doesNotMatch(s, /import\s*['"][^'"]*\.(css|png|svg)['"]/, `${f}: Import von CSS oder Bild`)
|
||||||
|
assert.doesNotMatch(s, /innerHTML|dangerouslySetInnerHTML/, f)
|
||||||
|
assert.doesNotMatch(s, /\burl\(/, `${f}: Bild im Quelltext`)
|
||||||
|
assert.equal((s.match(/<button\b/g) ?? []).length, (s.match(/<button\b[^>]*\btype="button"/g) ?? []).length, `${f}: Knopf ohne type`)
|
||||||
|
// jeder externe Link öffnet ohne Rückbezug
|
||||||
|
for (const m of s.matchAll(/<a\b[^>]*\btarget="_blank"[^>]*>/g)) assert.match(m[0], /rel="noopener noreferrer"/, f)
|
||||||
|
}
|
||||||
|
// Links aus Dienst-Daten laufen nur über rowModel (sourceHref/previewHref/mailtoHref), nie über den rohen Eintrag
|
||||||
|
const brief = read('src/components/workers/pult/Brief.jsx')
|
||||||
|
assert.doesNotMatch(brief, /href=\{item\./); assert.doesNotMatch(brief, /sourceUrl/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pult: Handy unter 720 px – Klemmbrett in voller Breite über abgedunkelter Szene, Brief als Vollbild (CSS)', () => {
|
||||||
|
const css = read('src/styles/hustler/hustler-pult-ui.css')
|
||||||
|
const phone = css.slice(css.indexOf('@media (max-width: 719px)'))
|
||||||
|
assert.ok(phone.length > 0 && css.includes('@media (max-width: 719px)'))
|
||||||
|
assert.match(phone, /\.hk-pult-ui\s*\{[^}]*width:\s*auto/); assert.match(phone, /\.hk-pult-ui\s*\{[^}]*left:\s*8px/)
|
||||||
|
assert.match(phone, /\.hk-pult-vignette\s*\{[^}]*rgba\(6, 7, 18, 0\.55\)/)
|
||||||
|
assert.match(phone, /\.hk-brief-ueber\s*\{[^}]*padding:\s*0/); assert.match(phone, /\.hk-brief\s*\{[^}]*width:\s*100%[^}]*height:\s*100%/)
|
||||||
|
// am Desktop: Klemmbrett rechts, höchstens 716 px breit; Brief höchstens 640 px
|
||||||
|
assert.match(css, /\.hk-pult \.hk-pult-ui\s*\{[^}]*width:\s*min\(716px, calc\(100% - 32px\)\)/); assert.match(css, /\.hk-pult \.hk-brief\s*\{[^}]*width:\s*min\(640px, 100%\)/)
|
||||||
|
// „weniger Bewegung“ steht im erzeugten CSS der Vorlage
|
||||||
|
assert.ok(read('src/styles/hustler/pult.css').includes('@media (prefers-reduced-motion: reduce)'))
|
||||||
|
})
|
||||||
@@ -415,12 +415,14 @@ test('ProspectPanel: Dialog mit Ladezustand und Fehler, lädt über api.prospect
|
|||||||
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList: "Details" öffnet das Interessenten-Panel, Schließen räumt die Auswahl weg', () => {
|
test('PultList: der Knopf „Interessent“ im Brief öffnet das Interessenten-Panel (in der WOMS-Hülle), Schließen räumt die Auswahl weg', () => {
|
||||||
const s = read('src/components/workers/PultList.jsx')
|
const s = read('src/components/workers/PultList.jsx')
|
||||||
assert.match(s, /import ProspectPanel from '\.\/ProspectPanel'/)
|
assert.match(s, /import ProspectPanel from '\.\/ProspectPanel'/)
|
||||||
assert.match(s, /const \[detailId, setDetailId\] = useState\(null\)/)
|
assert.match(s, /const \[detailId, setDetailId\] = useState\(null\)/)
|
||||||
assert.match(s, /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/)
|
// der Brief (pult/Brief.jsx) öffnet mit dem Knopf „Interessent“ das Panel der Liste; es steht im Pult in der Hülle hk-woms (WOMS-Aussehen statt Pixel-Schrift)
|
||||||
assert.match(s, /\{detailId && <ProspectPanel api=\{api\} prospectId=\{detailId\} onClose=\{\(\) => setDetailId\(null\)\} \/>\}/)
|
assert.match(read('src/components/workers/pult/Brief.jsx'), /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/)
|
||||||
|
assert.match(s, /onDetails=\{setDetailId\}/)
|
||||||
|
assert.match(s, /\{detailId && \(\s*<div className="hk-woms">\s*<ProspectPanel api=\{api\} prospectId=\{detailId\} onClose=\{\(\) => setDetailId\(null\)\} \/>\s*<\/div>\s*\)\}/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('CSS: wk-pult-text bricht Zeilen wie im Text (pre-wrap) und lange Wörter um; Panel-Klassen sind definiert', () => {
|
test('CSS: wk-pult-text bricht Zeilen wie im Text (pre-wrap) und lange Wörter um; Panel-Klassen sind definiert', () => {
|
||||||
|
|||||||
+304
-201
@@ -7,39 +7,52 @@ import esbuild from 'esbuild'
|
|||||||
import { fakeReact } from './helpers/fakeReact.mjs'
|
import { fakeReact } from './helpers/fakeReact.mjs'
|
||||||
|
|
||||||
// Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik
|
// Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik
|
||||||
// steckt in P3-T18. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (helpers/fakeReact.mjs), PultList zusätzlich als Text gerendert.
|
// steckt in P3-T18. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (helpers/fakeReact.mjs), PultList und Brief zusätzlich als Text gerendert.
|
||||||
|
// Hustler-Pixelwelt T9: aus der Zeile (PultRow) wurde der Brief (pult/Brief.jsx), die Liste zeigt Umschläge (pult/Klemmbrett.jsx). Die fachlichen Zusicherungen gelten
|
||||||
|
// unverändert; wo früher in der Zeile geklickt wurde, wird jetzt erst der Umschlag geöffnet. Der Quelltext des Pults liegt in mehreren Dateien: pultSource() fügt sie zusammen.
|
||||||
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
|
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
|
||||||
|
const PULT_DATEIEN = [
|
||||||
|
'src/components/workers/PultList.jsx', 'src/components/workers/pult/Brief.jsx', 'src/components/workers/pult/Klemmbrett.jsx',
|
||||||
|
'src/components/workers/pult/Marken.jsx', 'src/components/workers/pult/PultSzene.jsx', 'src/lib/hustlerPult.js',
|
||||||
|
]
|
||||||
|
const pultSource = () => PULT_DATEIEN.map(read).join('\n')
|
||||||
|
|
||||||
test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => {
|
test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => {
|
||||||
const s = read('src/pages/WorkersPage.jsx')
|
const s = read('src/pages/WorkersPage.jsx')
|
||||||
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /<PultList[^>]*api=\{api\}/)
|
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /<PultList[^>]*api=\{api\}/)
|
||||||
})
|
})
|
||||||
test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => {
|
test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => {
|
||||||
const s = read('src/components/workers/PultList.jsx')
|
const s = pultSource()
|
||||||
assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
|
assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
|
||||||
assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
|
assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
|
||||||
assert.doesNotMatch(s, /readOnly[^>]*wk-pult-draft|wk-pult-draft[^>]*readOnly/)
|
assert.doesNotMatch(s, /readOnly[^>]*hk-brief-entwurf|hk-brief-entwurf[^>]*readOnly/)
|
||||||
})
|
})
|
||||||
test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => {
|
test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => {
|
||||||
const s = read('src/hooks/usePultFeed.js')
|
const s = read('src/hooks/usePultFeed.js')
|
||||||
assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/)
|
assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/)
|
||||||
})
|
})
|
||||||
test('CSS: wk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte wk-Klasse ist definiert', () => {
|
test('CSS: hk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte hk-Klasse ist definiert (ProspectPanel bleibt bei den wk-Klassen von workers.css)', () => {
|
||||||
const css = read('src/styles/workers.css')
|
const css = ['pult.css', 'hustler-ui.css', 'hustler-pult-ui.css'].map((f) => read('src/styles/hustler/' + f)).join('\n')
|
||||||
assert.match(css, /\.wk-pult-row/); assert.match(css, /@media \(max-width: 860px\)[\s\S]*\.wk-pult-row/)
|
const own = read('src/styles/hustler/hustler-pult-ui.css')
|
||||||
for (const f of ['PultList', 'ProspectPanel']) { const p = 'src/components/workers/' + f + '.jsx'; if (fs.existsSync(new URL('../../' + p, import.meta.url))) for (const m of read(p).matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) }
|
assert.match(own, /\.hk-pult \.hk-brief\b/); assert.match(own, /@media \(max-width: 719px\)[\s\S]*\.hk-brief\b/)
|
||||||
|
for (const f of PULT_DATEIEN) for (const m of read(f).matchAll(/(?<![\w-])hk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`)
|
||||||
|
const wk = read('src/styles/workers.css')
|
||||||
|
for (const m of read('src/components/workers/ProspectPanel.jsx').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(wk.includes('.' + m[0]), `ProspectPanel: ${m[0]}`)
|
||||||
|
// das Pult selbst benutzt keine wk-Klassen mehr
|
||||||
|
for (const f of PULT_DATEIEN) assert.doesNotMatch(read(f), /(?<![\w-])wk-[a-z0-9-]+/, f)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => {
|
test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => {
|
||||||
const s = read('src/components/workers/PultList.jsx')
|
const s = pultSource()
|
||||||
assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/)
|
assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/)
|
||||||
assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/)
|
assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/)
|
||||||
// Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug
|
// Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug
|
||||||
assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/)
|
assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/)
|
||||||
assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/)
|
assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/)
|
||||||
assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/)
|
assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/)
|
||||||
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t)
|
// „Details“ heißt im Brief „Interessent“ (Spec E11)
|
||||||
assert.match(s, /className=\{`msg \$\{/); assert.match(s, /'ok'/); assert.match(s, /'err'/)
|
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Interessent']) assert.ok(s.includes(t), t)
|
||||||
|
assert.match(s, /hk-hinweis-err/); assert.match(s, /hk-hinweis-ok/); assert.match(s, /'ok'/); assert.match(s, /'err'/)
|
||||||
// der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf
|
// der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf
|
||||||
assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/)
|
assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/)
|
||||||
// der Text erscheint nur als Textknoten
|
// der Text erscheint nur als Textknoten
|
||||||
@@ -223,12 +236,24 @@ test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async
|
|||||||
} finally { b.restore() }
|
} finally { b.restore() }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); Attrappen aus STUBS ----
|
// ---- PultList und Brief werden mit echtem React auf einen Text gerendert (renderToStaticMarkup); Attrappen aus STUBS ----
|
||||||
|
// Die Liste zeigt Umschläge; die Aktionen stehen im Brief. Der Brief wird für sich gerendert, mit derselben rowModel wie in der Seite.
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
return importBundle(await esbuild.build({
|
return importBundle(await esbuild.build({
|
||||||
stdin: {
|
stdin: {
|
||||||
contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))",
|
contents: [
|
||||||
|
"import { createElement } from 'react'",
|
||||||
|
"import { renderToStaticMarkup } from 'react-dom/server'",
|
||||||
|
"import PultList from './src/components/workers/PultList.jsx'",
|
||||||
|
"import Brief from './src/components/workers/pult/Brief.jsx'",
|
||||||
|
"import { rowModel } from './src/lib/workersPult.js'",
|
||||||
|
'export const render = (props) => renderToStaticMarkup(createElement(PultList, props))',
|
||||||
|
'export const renderBrief = (item, { me, nowMs, employees, text, fields } = {}) => {',
|
||||||
|
' const row = rowModel(item, { me, nowMs, employees })',
|
||||||
|
' return renderToStaticMarkup(createElement(Brief, { api: {}, item, row, text: text ?? row.copyText, fields: fields ?? {}, onText() {}, onField() {}, refresh: async () => {}, onFinished() {}, onClose() {}, onDetails() {}, onSpaet() {} }))',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
resolveDir: ROOT, loader: 'js',
|
resolveDir: ROOT, loader: 'js',
|
||||||
},
|
},
|
||||||
bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs],
|
bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs],
|
||||||
@@ -237,125 +262,140 @@ async function loadList() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
|
// Stempel-Knopf (Brief) mit diesem Text: <button type="button" class="hk-stempel …">[Icon]Text</button>
|
||||||
const { render } = await loadList()
|
const stempel = (html, label) => new RegExp(`<button type="button" class="hk-stempel[^"]*"[^>]*>(?:<span[^>]*></span>)?${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</button>`).test(html)
|
||||||
|
|
||||||
|
test('Pult (gerendert): Aktionen je Art und Status im Brief, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
|
||||||
|
const { render, renderBrief } = await loadList()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const iso = (ms) => new Date(ms).toISOString()
|
const iso = (ms) => new Date(ms).toISOString()
|
||||||
const me = { id: 'u1', name: 'Justin' }
|
const me = { id: 'u1', name: 'Justin' }
|
||||||
|
const employees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||||||
|
const brief = (item) => renderBrief(item, { me, nowMs: now, employees })
|
||||||
const show = (items, extra = {}) => {
|
const show = (items, extra = {}) => {
|
||||||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {}, ...extra })
|
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {}, ...extra })
|
||||||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
globalThis.__pultEmployees = employees
|
||||||
return render({ api: {}, me })
|
return render({ api: {}, me })
|
||||||
}
|
}
|
||||||
const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } }
|
const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } }
|
||||||
try {
|
try {
|
||||||
// offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Details; das Textfeld trägt den Entwurf
|
// offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Interessent; das Textfeld trägt den Entwurf
|
||||||
let html = show([{ ...base, originalText: 'Original <img src=x onerror=alert(1)>', draft: 'Hallo <script>alert(1)</script>', title: 'T <b>x</b>' }])
|
let html = brief({ ...base, originalText: 'Original <img src=x onerror=alert(1)>', draft: 'Hallo <script>alert(1)</script>', title: 'T <b>x</b>' })
|
||||||
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Details', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t)
|
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Interessent', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t)
|
||||||
const erledigt = '<button class="btn" type="button">Erledigt</button>' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen
|
assert.ok(!stempel(html, 'Erledigt'))
|
||||||
assert.ok(!html.includes(erledigt)); assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer" aria-label="Anzeige öffnen: T <b>x<\/b>">/)
|
assert.match(html, /<a class="hk-link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer" aria-label="Anzeige öffnen: T <b>x<\/b>">/)
|
||||||
assert.match(html, /<textarea class="textarea wk-pult-draft"[^>]*>Hallo <script>alert\(1\)<\/script><\/textarea>/)
|
assert.match(html, /<textarea class="hk-feld hk-brief-entwurf"[^>]*>Hallo <script>alert\(1\)<\/script><\/textarea>/)
|
||||||
// Fremdtext erscheint nie als Markup
|
// Fremdtext erscheint nie als Markup
|
||||||
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original <img src=x onerror=alert(1)>'))
|
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original <img src=x onerror=alert(1)>'))
|
||||||
|
|
||||||
// Kollege hat übernommen: Name statt „Ich kümmere mich“, der Rest bleibt bedienbar (der Dienst entscheidet mit 409); javascript:-Adresse gibt keinen Link, nur „Kopieren“
|
// Kollege hat übernommen: Name statt „Ich kümmere mich“, der Rest bleibt bedienbar (der Dienst entscheidet mit 409); javascript:-Adresse gibt keinen Link, nur „Kopieren“
|
||||||
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2', sourceUrl: 'javascript:alert(1)' }])
|
html = brief({ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2', sourceUrl: 'javascript:alert(1)' })
|
||||||
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren'))
|
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren'))
|
||||||
assert.ok(!html.includes('href="javascript:')); assert.ok(!html.includes('Anzeige öffnen')); assert.match(html, />Kopieren<\/button>/)
|
assert.ok(!html.includes('href="javascript:')); assert.ok(!html.includes('Anzeige öffnen')); assert.ok(stempel(html, 'Kopieren'))
|
||||||
|
|
||||||
// veraltet und „läuft bald ab“
|
// veraltet und „läuft bald ab“ stehen als Marken am Umschlag
|
||||||
html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }])
|
html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }])
|
||||||
assert.ok(html.includes('<span class="badge orange">veraltet</span>')); assert.ok(html.includes('läuft in 5 Std. ab'))
|
assert.ok(html.includes('<span class="hk-marke hk-marke-veraltet"><span class="hk-ic hk-ic-klein hk-ic-veraltet" aria-hidden="true"></span>veraltet</span>'))
|
||||||
|
assert.ok(html.includes('<span class="hk-marke hk-marke-frist"><span class="hk-ic hk-ic-klein hk-ic-sanduhr-k" aria-hidden="true"></span>läuft in 5 Std. ab</span>'))
|
||||||
|
|
||||||
// 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 = brief({ ...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(stempel(html, 'Erledigt')); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
|
||||||
// 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)
|
// gesendet: nichts mehr zu senden oder zu verwerfen; ohne Interessent bleibt der Brief ohne Aktionen, mit Interessent nur „Antwort des Kunden einfügen“ (P4-T32, D13)
|
||||||
html = show([{ ...base, status: 'gesendet', prospect: null }])
|
html = brief({ ...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('hk-aktionen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<h3 class="hk-brief-h">Entwurf</h3>'))
|
||||||
html = show([{ ...base, status: 'gesendet' }])
|
assert.ok(html.includes('Dieser Eintrag ist abgeschlossen.'))
|
||||||
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'))
|
html = brief({ ...base, status: 'gesendet' })
|
||||||
|
assert.ok(html.includes('hk-aktionen')); 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'))
|
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: '' }
|
||||||
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } }])
|
html = brief({ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } })
|
||||||
assert.match(html, /<a class="btn btn-primary" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&body=Guten%20Tag%0D%0ABis%20bald">E-Mail schreiben<\/a>/)
|
assert.match(html, /<a class="hk-stempel hk-stempel-haupt" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&body=Guten%20Tag%0D%0ABis%20bald"><span class="hk-ic hk-ic-art-mail" aria-hidden="true"><\/span>E-Mail schreiben<\/a>/)
|
||||||
assert.ok(!html.includes('Kopieren'))
|
assert.ok(!html.includes('Kopieren'))
|
||||||
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example?bcc=z@w.de' } }])
|
html = brief({ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example?bcc=z@w.de' } })
|
||||||
assert.ok(!html.includes('mailto:')); assert.ok(html.includes('Keine gültige E-Mail-Adresse hinterlegt.'))
|
assert.ok(!html.includes('mailto:')); assert.ok(html.includes('Keine gültige E-Mail-Adresse hinterlegt.'))
|
||||||
|
|
||||||
// Zustände: laden, leer je Filter, Fehler ohne und mit Einträgen
|
// Zustände der Liste: laden, leer je Filter, Fehler ohne und mit Einträgen
|
||||||
assert.ok(show([], { loading: true }).includes('Wird geladen'))
|
assert.ok(show([], { loading: true }).includes('Wird geladen'))
|
||||||
assert.ok(show([]).includes('Nichts wartet auf dich'))
|
assert.ok(show([]).includes('Nichts wartet auf dich'))
|
||||||
html = show([], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
|
html = show([], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
|
||||||
assert.ok(html.includes('Das Pult ist gerade nicht erreichbar')); assert.ok(html.includes('Der Hustler-Dienst ist gerade nicht erreichbar.')); assert.ok(html.includes('Neu laden'))
|
assert.ok(html.includes('Das Pult ist gerade nicht erreichbar')); assert.ok(html.includes('Der Hustler-Dienst ist gerade nicht erreichbar.')); assert.ok(html.includes('Neu laden'))
|
||||||
html = show([base], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
|
html = show([base], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
|
||||||
assert.ok(html.includes('class="msg warn"')); assert.ok(html.includes('Suche Webdesigner'))
|
assert.ok(html.includes('hk-hinweis hk-hinweis-warn')); assert.ok(html.includes('Verbindung gestört: Der Hustler-Dienst ist gerade nicht erreichbar. Es wird weiter versucht.')); assert.ok(html.includes('Suche Webdesigner'))
|
||||||
// die Filter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt
|
// die Reiter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt (am offenen Reiter steht die Länge der geladenen Liste, an den anderen keine Zahl)
|
||||||
html = show([])
|
html = show([])
|
||||||
assert.match(html, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/)
|
assert.match(html, /<button type="button" class="hk-reiter hk-reiter-wartet hk-reiter-an" aria-pressed="true" aria-label="Wartet, 0 Einträge">Wartet<span class="hk-zahl">0<\/span><\/button><button type="button" class="hk-reiter hk-reiter-veraltet" aria-pressed="false">Veraltet<\/button><button type="button" class="hk-reiter hk-reiter-erledigt" aria-pressed="false">Erledigt<\/button>/)
|
||||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
|
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
|
||||||
test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => {
|
test('Pult (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => {
|
||||||
const { render } = await loadList()
|
const { render, renderBrief } = await loadList()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const iso = (ms) => new Date(ms).toISOString()
|
const iso = (ms) => new Date(ms).toISOString()
|
||||||
|
const employees = [{ userId: 'u2', displayName: 'Anna' }]
|
||||||
const show = (items) => {
|
const show = (items) => {
|
||||||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
||||||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Anna' }]
|
globalThis.__pultEmployees = employees
|
||||||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||||||
}
|
}
|
||||||
const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) }
|
const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) }
|
||||||
try {
|
try {
|
||||||
const html = show([
|
const items = [
|
||||||
{ ...base, $id: 'a', status: 'gesendet' },
|
{ ...base, $id: 'a', status: 'gesendet' },
|
||||||
{ ...base, $id: 'b', status: 'verworfen', title: 'Zweite' },
|
{ ...base, $id: 'b', status: 'verworfen', title: 'Zweite' },
|
||||||
{ ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' },
|
{ ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' },
|
||||||
])
|
]
|
||||||
assert.ok(html.includes('<span class="badge green">Gesendet</span>'))
|
for (const html of [show(items), ...items.map((item) => renderBrief(item, { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees }))]) {
|
||||||
assert.ok(html.includes('<span class="badge">Verworfen</span>')); assert.ok(html.includes('<span class="badge">Erledigt</span>'))
|
|
||||||
assert.ok(html.includes('<span>Gesendet von Anna · vor 2 Std.</span>')); assert.ok(html.includes('<span>Verworfen von Anna · vor 2 Std.</span>'))
|
|
||||||
assert.ok(html.includes('<span>Erledigt von Max (automatisch) · vor 2 Std.</span>'))
|
|
||||||
assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da
|
assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da
|
||||||
assert.ok(!html.includes('Kollege'))
|
assert.ok(!html.includes('Kollege'))
|
||||||
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Badge
|
}
|
||||||
|
const list = show(items)
|
||||||
|
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-gruen">Gesendet von Anna · vor 2 Std.</span>'))
|
||||||
|
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-grau">Verworfen von Anna · vor 2 Std.</span>'))
|
||||||
|
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-grau">Erledigt von Max (automatisch) · vor 2 Std.</span>'))
|
||||||
|
assert.ok(renderBrief(items[0], { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees }).includes('<span class="hk-abdruck hk-stempel-gruen">Gesendet von Anna · vor 2 Std.</span>'))
|
||||||
|
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Stempel
|
||||||
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
|
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
|
||||||
assert.ok(open.includes('<span>vor 4 Tagen</span>')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('badge green'))
|
assert.ok(open.includes('Antwort auf Plattform-Anfrage · vor 4 Tagen')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('hk-abdruck'))
|
||||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Endprüfung P3 (W8): „Details“, „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel der Zeile im zugänglichen Namen
|
// Endprüfung P3 (W8): „Interessent“ (früher „Details“), „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel des Eintrags im zugänglichen Namen
|
||||||
test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => {
|
test('Pult (gerendert): wiederkehrende Bedienelemente haben je Eintrag einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => {
|
||||||
const { render } = await loadList()
|
const { render, renderBrief } = await loadList()
|
||||||
const show = (items) => {
|
const me = { id: 'u1', name: 'Justin' }
|
||||||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
|
||||||
globalThis.__pultEmployees = []
|
|
||||||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
|
||||||
}
|
|
||||||
const base = { kind: 'plattform_antwort', status: 'offen', draft: 'Hallo', createdAt: new Date().toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
const base = { kind: 'plattform_antwort', status: 'offen', draft: 'Hallo', createdAt: new Date().toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||||||
|
const brief = (item) => renderBrief(item, { me, nowMs: Date.now(), employees: [] })
|
||||||
try {
|
try {
|
||||||
const html = show([{ ...base, $id: 'a', title: 'Suche Webdesigner' }, { ...base, $id: 'b', title: 'Homepage für Handwerker <b>x</b> & "Co"' }])
|
const html = brief({ ...base, $id: 'a', title: 'Suche Webdesigner' })
|
||||||
for (const name of ['Details zu Suche Webdesigner', 'Anzeige öffnen: Suche Webdesigner', 'Entwurf der Antwort: Suche Webdesigner', 'Aktionen: Suche Webdesigner']) {
|
for (const name of ['Interessent zu Suche Webdesigner', 'Anzeige öffnen: Suche Webdesigner', 'Entwurf der Antwort: Suche Webdesigner', 'Aktionen: Suche Webdesigner']) {
|
||||||
assert.ok(html.includes(`aria-label="${name}"`), name)
|
assert.ok(html.includes(`aria-label="${name}"`), name)
|
||||||
}
|
}
|
||||||
// zwei Zeilen ⇒ zwei verschiedene Namen; Fremdtext im Namen ist als Attribut kodiert
|
// Fremdtext im Namen ist als Attribut kodiert; zwei Einträge ⇒ zwei verschiedene Namen (Umschläge in der Liste, Brief je Eintrag)
|
||||||
const names = [...html.matchAll(/aria-label="(Details zu [^"]*)"/g)].map((m) => m[1])
|
const titles = ['Suche Webdesigner', 'Homepage für Handwerker <b>x</b> & "Co"']
|
||||||
|
const second = brief({ ...base, $id: 'b', title: titles[1] })
|
||||||
|
assert.ok(second.includes('aria-label="Interessent zu Homepage für Handwerker <b>x</b> & "Co""'))
|
||||||
|
assert.ok(second.includes('aria-label="Aktionen: Homepage für Handwerker <b>x</b> & "Co""'))
|
||||||
|
assert.ok(!second.includes('<b>x</b>'))
|
||||||
|
globalThis.__pultFeedFor = () => ({ items: [{ ...base, $id: 'a', title: titles[0] }, { ...base, $id: 'b', title: titles[1] }], loading: false, error: null, refresh: async () => {} })
|
||||||
|
globalThis.__pultEmployees = []
|
||||||
|
const list = render({ api: {}, me })
|
||||||
|
const names = [...list.matchAll(/class="hk-umschlag" aria-haspopup="dialog" aria-label="([^"]*)"/g)].map((m) => m[1])
|
||||||
assert.equal(names.length, 2); assert.equal(new Set(names).size, 2)
|
assert.equal(names.length, 2); assert.equal(new Set(names).size, 2)
|
||||||
assert.ok(html.includes('aria-label="Details zu Homepage für Handwerker <b>x</b> & "Co""'))
|
assert.ok(names[1].includes('Homepage für Handwerker <b>x</b> & "Co"')); assert.ok(!list.includes('<b>x</b>'))
|
||||||
assert.ok(!html.includes('<b>x</b>'))
|
|
||||||
// das sichtbare Wort bleibt (WCAG 2.5.3) und steht im Namen vorn
|
// das sichtbare Wort bleibt (WCAG 2.5.3) und steht im Namen vorn
|
||||||
assert.match(html, /aria-label="Details zu Suche Webdesigner">Details<\/button>/)
|
assert.match(html, /aria-label="Interessent zu Suche Webdesigner">Interessent<\/button>/)
|
||||||
assert.match(html, /aria-label="Anzeige öffnen: Suche Webdesigner">Anzeige öffnen<\/a>/)
|
assert.match(html, /aria-label="Anzeige öffnen: Suche Webdesigner">Anzeige öffnen<\/a>/)
|
||||||
// leerer oder fehlender Titel: schlichter Name, nie „zu undefined“
|
// leerer oder fehlender Titel: schlichter Name, nie „zu undefined“
|
||||||
for (const title of ['', ' ', undefined]) {
|
for (const title of ['', ' ', undefined]) {
|
||||||
const bare = show([{ ...base, $id: 'c', title }])
|
const bare = brief({ ...base, $id: 'c', title })
|
||||||
assert.ok(!bare.includes('undefined'), String(title))
|
assert.ok(!bare.includes('undefined'), String(title))
|
||||||
assert.ok(!bare.includes('Details zu'), String(title)); assert.ok(!bare.includes('Anzeige öffnen:'), String(title))
|
assert.ok(!bare.includes('Interessent zu'), String(title)); assert.ok(!bare.includes('Anzeige öffnen:'), String(title))
|
||||||
assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title))
|
assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title))
|
||||||
assert.match(bare, />Details<\/button>/)
|
assert.match(bare, />Interessent<\/button>/)
|
||||||
}
|
}
|
||||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||||||
})
|
})
|
||||||
@@ -363,12 +403,12 @@ test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen e
|
|||||||
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ----
|
// ---- 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', () => {
|
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')
|
const s = pultSource()
|
||||||
for (const t of [
|
for (const t of [
|
||||||
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
|
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
|
||||||
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
|
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
|
||||||
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?',
|
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?',
|
||||||
'Antwort des Kunden einfügen', 'Einfügen', 'wk-pult-reply', 'wk-pult-paste', 'Abbrechen', 'data-opens', 'Escape', 'restoreFocus', 'activeControl', 'tabIndex={-1}',
|
'Antwort des Kunden einfügen', 'Einfügen', 'hk-feld-antwort', 'hk-feld-einfuegen', 'Abbrechen', 'data-opens', 'Escape', 'restoreFocus', 'activeControl', 'tabIndex={-1}',
|
||||||
'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.',
|
'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.ok(s.includes(t), t)
|
||||||
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
|
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
|
||||||
@@ -377,7 +417,7 @@ test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit v
|
|||||||
assert.match(s, /api\.pultAction\(row\.id, 'approve-preview'\)/)
|
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, 'start-build'\)/)
|
||||||
assert.match(s, /api\.pultAction\(row\.id, 'paste', \{ text: [\w.]+ \}\)/)
|
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
|
// beide Texte werden vor dem Senden mit validateReplyText geprüft, ein Fehler erscheint als Meldung im Brief
|
||||||
assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2)
|
assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2)
|
||||||
// die Vorschau-Adresse kommt aus rowModel (previewHref) und öffnet ohne Rückbezug in einem neuen Tab
|
// 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"/)
|
assert.match(s, /href=\{row\.previewHref\}[^>]*target="_blank"[^>]*rel="noopener noreferrer"/)
|
||||||
@@ -386,117 +426,110 @@ test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit v
|
|||||||
// „Als Mensch antworten“ ist mit dem Entwurf vorbelegt, „Einfügen“ nie: die Felder sind getrennt und das Einfüge-Feld beginnt leer
|
// „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.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|'')/)
|
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“)
|
// das Fehlerschema des Briefs bleibt: Meldung ok|err, Fehlertext über actionErrorText (D14: Zeitüberschreitung ⇒ „Kann gesendet sein“)
|
||||||
assert.match(s, /actionErrorText\(err, action\)/)
|
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)', () => {
|
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')
|
const s = pultSource()
|
||||||
assert.match(s, /row\.canSend \|\| row\.canClaim \|\| row\.canDone \|\| row\.canDiscard \|\| row\.canReply \|\| row\.canApprovePreview \|\| row\.canStartBuild \|\| row\.canPaste/)
|
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', () => {
|
test('CSS (P4-T32): Antwort- und Einfüge-Feld mit Zähler und zwei Knöpfen, auf dem Handy untereinander (Abbrechen unten, wie bisher)', () => {
|
||||||
const css = read('src/styles/workers.css')
|
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
assert.match(css, /\.wk-pult-reply/); assert.match(css, /\.wk-pult-paste/)
|
assert.match(css, /\.hk-pult \.hk-feld-panel\s*\{/); assert.match(css, /\.hk-pult \.hk-zaehler-ueber\s*\{/)
|
||||||
const rule = css.match(/\.wk-pult-row \.wk-pult-reply[^{]*\{[^}]*\}/)?.[0] ?? ''
|
assert.match(css, /\.hk-pult \.hk-feld-knoepfe\s*\{[^}]*display:\s*flex/)
|
||||||
assert.match(rule, /\.wk-pult-paste/); assert.match(rule, /border-top: 1px solid var\(--line\)/); assert.match(rule, /width: 100%/)
|
// schmale Bildschirme (unter 720 px, Spec E13): die Fußzeile des Feldes (Zähler, Knöpfe) steht untereinander, „Abbrechen“ als letzter
|
||||||
// schmale Bildschirme: die Fußzeile des Feldes (Zähler, Knöpfe) steht untereinander
|
const phone = css.slice(css.indexOf('@media (max-width: 719px)'))
|
||||||
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
|
assert.match(phone, /\.hk-feld-fuss\s*\{[^}]*flex-direction:\s*column/); assert.match(phone, /\.hk-feld-knoepfe\s*\{[^}]*flex-direction:\s*column-reverse/)
|
||||||
assert.match(phone, /\.wk-pult-panel-foot/); assert.match(phone, /flex-direction: column/)
|
|
||||||
// Endprüfung W4: „Abbrechen“ und „Jetzt senden“ stehen nebeneinander (Gruppe .wk-pult-panel-btns), auf dem Handy untereinander über die volle Breite
|
|
||||||
assert.match(css, /\.wk-pult-panel-btns\s*\{[^}]*display: flex/); assert.match(phone, /\.wk-pult-panel-btns\s*\{[^}]*flex-direction: column-reverse/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (gerendert, P4-T32): Aktionen je Art – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => {
|
test('Pult (gerendert, P4-T32): Aktionen je Art im Brief – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => {
|
||||||
const { render } = await loadList()
|
const { renderBrief } = await loadList()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const iso = (ms) => new Date(ms).toISOString()
|
const iso = (ms) => new Date(ms).toISOString()
|
||||||
const show = (items) => {
|
const employees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||||||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
const show = (item) => renderBrief(item, { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees })
|
||||||
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 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)
|
const buttonOf = stempel
|
||||||
try {
|
|
||||||
// unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu
|
// unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu
|
||||||
let html = show([base])
|
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 ['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)
|
for (const t of ['Antwort des Kunden einfügen', 'Freigeben', 'Jetzt senden', 'hk-feld-antwort', 'hk-feld-einfuegen', '<textarea']) assert.ok(!html.includes(t), t)
|
||||||
assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>Als Mensch antworten<\/button>/)
|
assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>(?:<span[^>]*><\/span>)?Als Mensch antworten<\/button>/)
|
||||||
|
|
||||||
// „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen
|
// „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen
|
||||||
html = show([{ ...base, prospectId: undefined, prospect: null }])
|
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'))
|
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
|
// Interessent ohne Adresse (Plattform): kein Mail-Kanal ⇒ nicht antworten, aber bauen
|
||||||
html = show([{ ...base, prospect: { $id: 'p1', name: 'Verein' } }])
|
html = show({ ...base, prospect: { $id: 'p1', name: 'Verein' } })
|
||||||
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen'))
|
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen'))
|
||||||
// Endprüfung P4 (W2): ein toter Interessent (gesperrt, verloren, Deal) bekommt keinen Knopf zum Bauen, ein lebender behält ihn
|
// Endprüfung P4 (W2): ein toter Interessent (gesperrt, verloren, Deal) bekommt keinen Knopf zum Bauen, ein lebender behält ihn
|
||||||
for (const status of ['gesperrt', 'verloren', 'deal']) {
|
for (const status of ['gesperrt', 'verloren', 'deal']) {
|
||||||
html = show([{ ...base, prospect: { ...base.prospect, status } }])
|
html = show({ ...base, prospect: { ...base.prospect, status } })
|
||||||
assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(buttonOf(html, 'Als Mensch antworten'), status); assert.ok(buttonOf(html, 'Erledigt'), status)
|
assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(buttonOf(html, 'Als Mensch antworten'), status); assert.ok(buttonOf(html, 'Erledigt'), status)
|
||||||
}
|
}
|
||||||
assert.ok(buttonOf(show([{ ...base, prospect: { ...base.prospect, status: 'gespraech' } }]), 'Vorschau bauen'))
|
assert.ok(buttonOf(show({ ...base, prospect: { ...base.prospect, status: 'gespraech' } }), 'Vorschau bauen'))
|
||||||
// von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher
|
// von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher
|
||||||
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
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'))
|
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“
|
// von Max übernommen: „Max (automatisch)“, nie „Kollege“
|
||||||
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'max' }])
|
html = show({ ...base, status: 'in_arbeit', claimedBy: 'max' })
|
||||||
assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege'))
|
assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege'))
|
||||||
// mail_antwort: Mail-Link und Antwort-Funktion nebeneinander
|
// mail_antwort: Mail-Link und Antwort-Funktion nebeneinander
|
||||||
html = show([{ ...base, kind: 'mail_antwort', draft: 'Guten Tag' }])
|
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'))
|
assert.ok(html.includes('E-Mail schreiben')); assert.ok(html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen'))
|
||||||
// preisfrage, mensch_gewuenscht: antworten
|
// preisfrage, mensch_gewuenscht: antworten
|
||||||
for (const kind of ['preisfrage', 'mensch_gewuenscht']) assert.ok(show([{ ...base, kind }]).includes('Als Mensch antworten'), kind)
|
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'))
|
assert.ok(!show({ ...base, kind: 'beschwerde' }).includes('Als Mensch antworten'))
|
||||||
// abgeschlossen: keine Antwort-Funktion mehr
|
// abgeschlossen: keine Antwort-Funktion mehr
|
||||||
for (const status of ['gesendet', 'erledigt', 'verworfen']) {
|
for (const status of ['gesendet', 'erledigt', 'verworfen']) {
|
||||||
html = show([{ ...base, status }])
|
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)
|
assert.ok(!html.includes('Als Mensch antworten'), status); assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(!html.includes('hk-aktionen'), status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// vorschau_freigabe: Vorschau ansehen (sicherer Link) + Freigeben & senden + Hinweis zu Verwerfen; kein Erledigt, keine Anzeige-Adresse, keine Antwort-Funktion
|
// 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/' }
|
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])
|
html = show(freigabe)
|
||||||
// der Name trägt den Titel der Zeile (W8), Fremdtext darin ist kodiert
|
// der Name trägt den Titel des Eintrags (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.match(html, /<a class="hk-stempel hk-stempel-normal" href="https:\/\/entwurf\.project\.webklar\.com\/AAA\/" target="_blank" rel="noopener noreferrer" aria-label="Vorschau ansehen: Vorschau <b>prüfen<\/b>"><span class="hk-ic hk-ic-ansehen" aria-hidden="true"><\/span>Vorschau ansehen<\/a>/)
|
||||||
assert.ok(html.includes('Freigeben & senden')); assert.ok(buttonOf(html, 'Verwerfen')); assert.ok(html.includes('sendet die Vorschau nicht'))
|
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)
|
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>'))
|
assert.ok(!buttonOf(html, 'Erledigt')); assert.ok(!html.includes('<b>prüfen</b>'))
|
||||||
// gültige Adresse: der Knopf zum Freigeben ist bedienbar
|
// gültige Adresse: der Knopf zum Freigeben ist bedienbar
|
||||||
assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>Freigeben & senden<\/button>/)
|
assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Freigeben & senden<\/button>/)
|
||||||
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, und der Knopf zum Freigeben ist gesperrt (Endprüfung W5: der Mensch soll keine
|
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, und der Knopf zum Freigeben ist gesperrt (Endprüfung W5: der Mensch soll keine
|
||||||
// Vorschau freigeben, die er nicht ansehen kann); der Hinweis nennt den Grund und den Ausweg
|
// Vorschau freigeben, die er nicht ansehen kann); der Hinweis nennt den Grund und den Ausweg
|
||||||
for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) {
|
for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) {
|
||||||
html = show([{ ...freigabe, sourceUrl }])
|
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('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. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.'), sourceUrl)
|
assert.ok(html.includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.'), sourceUrl)
|
||||||
assert.match(html, /<button[^>]*disabled[^>]*>Freigeben & senden<\/button>/, sourceUrl)
|
assert.match(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Freigeben & senden<\/button>/, sourceUrl)
|
||||||
assert.ok(buttonOf(html, 'Verwerfen')); assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>Verwerfen<\/button>/, sourceUrl) // Verwerfen bleibt frei
|
assert.ok(buttonOf(html, 'Verwerfen')); assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Verwerfen<\/button>/, sourceUrl) // Verwerfen bleibt frei
|
||||||
}
|
}
|
||||||
// freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben
|
// freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben
|
||||||
assert.ok(!show([{ ...freigabe, status: 'erledigt' }]).includes('Freigeben'))
|
assert.ok(!show({ ...freigabe, status: 'erledigt' }).includes('Freigeben'))
|
||||||
html = show([{ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
html = show({ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' })
|
||||||
assert.ok(!html.includes('Freigeben')); assert.ok(buttonOf(html, 'Verwerfen'))
|
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
|
// 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' } }
|
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']) {
|
for (const status of ['offen', 'veraltet', 'gesendet', 'erledigt']) {
|
||||||
html = show([{ ...plattform, status }])
|
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)
|
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen'), status); assert.ok(!html.includes('hk-feld-einfuegen'), status); assert.ok(!html.includes('Als Mensch antworten'), status)
|
||||||
}
|
}
|
||||||
html = show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
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(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, status: 'verworfen' }).includes('Antwort des Kunden einfügen'))
|
||||||
assert.ok(!show([{ ...plattform, prospect: null }]).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>/)
|
assert.match(show({ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }), /aria-expanded="false"[^>]*>(?:<span[^>]*><\/span>)?Antwort des Kunden einfügen<\/button>/)
|
||||||
// in den anderen Arten gibt es kein Einfügen
|
// 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)
|
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show({ ...base, kind }).includes('Antwort des Kunden einfügen'), kind)
|
||||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- PultList wird bedient (P4-T32): derselbe Mini-Läufer (helpers/fakeReact.mjs) statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
|
// ---- Das Pult wird bedient (P4-T32): derselbe Mini-Läufer (helpers/fakeReact.mjs) 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. ----
|
// ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ----
|
||||||
|
// ---- Seit T9 sitzen die Aktionen im Brief: ui.click(label) öffnet erst den Umschlag des Eintrags (ui.open), wenn dessen Brief noch nicht offen ist. ----
|
||||||
|
|
||||||
async function loadUi() {
|
async function loadUi() {
|
||||||
return importBundle(await esbuild.build({
|
return importBundle(await esbuild.build({
|
||||||
@@ -515,14 +548,16 @@ const nodes = (node, test, out = []) => {
|
|||||||
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
|
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 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)
|
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 EMPTY_TREE = { tag: 'fragment', props: {}, children: [] }
|
||||||
const inList = (tree) => nodes(tree, (n) => n.tag === 'ul' && hasClass(n, 'wk-pult-list'))[0] ?? { tag: 'fragment', props: {}, children: [] }
|
// Der offene Brief (null, wenn keiner offen ist), die Umschläge der Liste und die Reiter
|
||||||
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'wk-pult-filters'))[0]
|
const briefOf = (tree) => nodes(tree, (n) => n.tag === 'section' && hasClass(n, 'hk-brief'))[0] ?? null
|
||||||
|
const envelopes = (tree) => nodes(tree, (n) => n.tag === 'button' && hasClass(n, 'hk-umschlag'))
|
||||||
|
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'hk-reiter-leiste'))[0]
|
||||||
const fieldOf = (tree, labelStart) => nodes(tree, (n) => n.tag === 'textarea' && String(n.props['aria-label'] ?? '').startsWith(labelStart))
|
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 }))
|
const messages = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'hk-hinweis')).map((n) => ({ kind: String(n.props.className).split(' ').find((c) => c.startsWith('hk-hinweis-')).slice('hk-hinweis-'.length), 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
|
// 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) {
|
async function startList(items, api, props = {}) {
|
||||||
const { __mount, jsx, PultList } = await loadUi()
|
const { __mount, jsx, PultList } = await loadUi()
|
||||||
const refreshes = []
|
const refreshes = []
|
||||||
const timers = new Map()
|
const timers = new Map()
|
||||||
@@ -536,20 +571,50 @@ async function startList(items, api) {
|
|||||||
}
|
}
|
||||||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||||||
globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } })
|
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 mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' }, ...props }))
|
||||||
|
let current = null // Nummer des Umschlags, dessen Brief offen ist
|
||||||
const view = {
|
const view = {
|
||||||
refreshes, timers, opened,
|
refreshes, timers, opened,
|
||||||
tree: () => mounted.tree(),
|
tree: () => mounted.tree(),
|
||||||
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung; ein gesperrter Knopf löst nichts aus (wie im Browser)
|
brief: () => briefOf(view.tree()),
|
||||||
// row: Nummer der Zeile, wenn mehrere Zeilen denselben Knopf haben
|
// Brief öffnen: Klick auf den Umschlag (Nummer in der Liste)
|
||||||
|
async open(index = 0) {
|
||||||
|
const found = envelopes(view.tree())[index]
|
||||||
|
assert.ok(found, `Umschlag ${index}`)
|
||||||
|
found.props.onClick({ preventDefault() {} })
|
||||||
|
current = index
|
||||||
|
await settle()
|
||||||
|
assert.ok(view.brief(), 'der Brief ist offen')
|
||||||
|
},
|
||||||
|
// Brief schließen: der Knopf „Schließen“
|
||||||
|
async close() {
|
||||||
|
const found = nodes(view.brief(), (n) => n.tag === 'button' && n.props['aria-label'] === 'Schließen')
|
||||||
|
assert.equal(found.length, 1)
|
||||||
|
found[0].props.onClick()
|
||||||
|
current = null
|
||||||
|
await settle()
|
||||||
|
},
|
||||||
|
// sorgt dafür, dass der Brief des Umschlags (Standard: der offene, sonst der erste) offen ist
|
||||||
|
async ensureOpen(row) {
|
||||||
|
if (view.brief() === null) current = null
|
||||||
|
if (row === undefined) {
|
||||||
|
if (view.brief() === null) await view.open(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (view.brief() !== null && current === row) return
|
||||||
|
if (view.brief() !== null) await view.close()
|
||||||
|
await view.open(row)
|
||||||
|
},
|
||||||
|
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung im Brief; ein gesperrter Knopf löst nichts aus (wie im Browser)
|
||||||
|
// row: Nummer des Umschlags, wenn mehrere Einträge denselben Knopf haben
|
||||||
async click(label, row) {
|
async click(label, row) {
|
||||||
const scope = row === undefined ? inList(view.tree()) : nodes(inList(view.tree()), (n) => n.tag === 'li')[row]
|
await view.ensureOpen(row)
|
||||||
const found = clickable(scope, label)
|
const found = clickable(view.brief(), label)
|
||||||
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
|
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
|
||||||
if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} })
|
if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} })
|
||||||
await settle()
|
await settle()
|
||||||
},
|
},
|
||||||
// Taste im Feld mit dieser Klasse (wk-pult-reply, wk-pult-paste); Ergebnis: das Ereignis, damit der Test preventDefault sehen kann
|
// Taste im Feld mit dieser Klasse (hk-feld-antwort, hk-feld-einfuegen); Ergebnis: das Ereignis, damit der Test preventDefault sehen kann
|
||||||
async key(panelClass, key = 'Escape') {
|
async key(panelClass, key = 'Escape') {
|
||||||
const panel = nodes(view.tree(), (n) => n.tag === 'div' && hasClass(n, panelClass))[0]
|
const panel = nodes(view.tree(), (n) => n.tag === 'div' && hasClass(n, panelClass))[0]
|
||||||
assert.ok(panel, 'Feld ' + panelClass)
|
assert.ok(panel, 'Feld ' + panelClass)
|
||||||
@@ -558,15 +623,22 @@ async function startList(items, api) {
|
|||||||
await settle()
|
await settle()
|
||||||
return event
|
return event
|
||||||
},
|
},
|
||||||
|
// Taste am Brief selbst (Esc, Tab)
|
||||||
|
async briefKey(key, extra = {}) {
|
||||||
|
const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true }, ...extra }
|
||||||
|
view.brief().props.onKeyDown(event)
|
||||||
|
await settle()
|
||||||
|
return event
|
||||||
|
},
|
||||||
async tab(label) {
|
async tab(label) {
|
||||||
const found = clickable(inFilters(view.tree()), label)
|
const found = nodes(inFilters(view.tree()), (n) => n.tag === 'button' && textOf(n).startsWith(label))
|
||||||
assert.equal(found.length, 1, `Reiter „${label}“`)
|
assert.equal(found.length, 1, `Reiter „${label}“`)
|
||||||
found[0].props.onClick()
|
found[0].props.onClick()
|
||||||
await settle()
|
await settle()
|
||||||
},
|
},
|
||||||
link: (label) => clickable(inList(view.tree()), label)[0],
|
link: (label) => clickable(view.brief() ?? EMPTY_TREE, label)[0],
|
||||||
has: (label) => clickable(inList(view.tree()), label).length > 0,
|
has: (label) => clickable(view.brief() ?? EMPTY_TREE, label).length > 0,
|
||||||
disabled: (label) => clickable(inList(view.tree()), label).map((n) => n.props.disabled === true),
|
disabled: (label) => clickable(view.brief() ?? EMPTY_TREE, label).map((n) => n.props.disabled === true),
|
||||||
async type(labelStart, value) {
|
async type(labelStart, value) {
|
||||||
const found = fieldOf(view.tree(), labelStart)
|
const found = fieldOf(view.tree(), labelStart)
|
||||||
assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`)
|
assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`)
|
||||||
@@ -598,7 +670,7 @@ function scriptedApi(...outcomes) {
|
|||||||
const failure = (message, status) => Object.assign(new Error(message), { status })
|
const failure = (message, status) => Object.assign(new Error(message), { status })
|
||||||
|
|
||||||
// document-Attrappe für den Fokus (nur für die Dauer eines Tests): activeElement stellt der Test, focus() eines Elements setzt es. Elemente, die der Bauteil über
|
// document-Attrappe für den Fokus (nur für die Dauer eines Tests): activeElement stellt der Test, focus() eines Elements setzt es. Elemente, die der Bauteil über
|
||||||
// ref anfasst (Zeile, Aktionsgruppe, Meldung), liefert __fakeRef je Klasse; querySelector antwortet aus f.found (Selektor -> Element), sonst null.
|
// ref anfasst (Brief, Aktionsgruppe, Meldung, erster Knopf, Textfeld), liefert __fakeRef je Klasse; querySelector antwortet aus f.found (Selektor -> Element), sonst null.
|
||||||
function fakeFocus() {
|
function fakeFocus() {
|
||||||
const body = { name: 'body' }
|
const body = { name: 'body' }
|
||||||
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
|
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
|
||||||
@@ -623,7 +695,7 @@ function fakeFocus() {
|
|||||||
|
|
||||||
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' } }
|
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 () => {
|
test('Pult (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 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)
|
const ui = await startList([unsicher], api)
|
||||||
try {
|
try {
|
||||||
@@ -633,15 +705,15 @@ test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit
|
|||||||
assert.ok(ui.text().includes('17 / 8000'))
|
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)
|
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
|
// leer ⇒ Hinweis im Brief, nichts geht hinaus
|
||||||
await ui.type('Text der Antwort', ' ')
|
await ui.type('Text der Antwort', ' ')
|
||||||
await ui.click('Jetzt senden')
|
await ui.click('Jetzt senden')
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }])
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }])
|
||||||
assert.equal(api.calls.length, 0)
|
assert.equal(api.calls.length, 0)
|
||||||
// zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus
|
// zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus
|
||||||
await ui.type('Text der Antwort', 'a'.repeat(8001))
|
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.match(textOf(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('hk-zaehler'))[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'))
|
assert.ok(String(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('hk-zaehler'))[0].props.className).includes('ueber'))
|
||||||
await ui.click('Jetzt senden')
|
await ui.click('Jetzt senden')
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }])
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }])
|
||||||
assert.equal(api.calls.length, 0)
|
assert.equal(api.calls.length, 0)
|
||||||
@@ -656,17 +728,20 @@ test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit
|
|||||||
await ui.click('Jetzt senden')
|
await ui.click('Jetzt senden')
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }])
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }])
|
||||||
assert.equal(ui.refreshes.length, 2)
|
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
|
// Erfolg: der Eintrag verlässt die Liste, der Brief schließt, die Meldung steht bei der Liste, die Liste wird neu geholt
|
||||||
await ui.click('Jetzt senden')
|
await ui.click('Jetzt senden')
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ gesendet.', role: 'status' }])
|
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.equal(ui.refreshes.length, 3); assert.equal(api.calls.length, 3)
|
||||||
|
assert.equal(ui.brief(), null)
|
||||||
|
// das Feld ist leer geräumt (beim erneuten Öffnen wieder beim Entwurf)
|
||||||
|
await ui.click('Als Mensch antworten')
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
||||||
} finally { ui.end() }
|
} 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 () => {
|
test('Pult (bedient, P4-T32): während einer Anfrage sind alle Knöpfe des Briefs gesperrt, ein zweiter Klick sendet nichts', async () => {
|
||||||
let finish
|
let reject
|
||||||
const pending = new Promise((resolve) => { finish = resolve })
|
const pending = new Promise((_, no) => { reject = no })
|
||||||
const api = scriptedApi(pending)
|
const api = scriptedApi(pending)
|
||||||
const ui = await startList([unsicher], api)
|
const ui = await startList([unsicher], api)
|
||||||
try {
|
try {
|
||||||
@@ -676,12 +751,14 @@ test('PultList (bedient, P4-T32): während einer Anfrage sind alle Knöpfe der Z
|
|||||||
for (const label of ['Jetzt senden', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [true], label)
|
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')
|
await ui.click('Jetzt senden'); await ui.click('Vorschau bauen')
|
||||||
assert.equal(api.calls.length, 1)
|
assert.equal(api.calls.length, 1)
|
||||||
finish({ item: { $id: 'e1' } }); await settle()
|
// der Dienst antwortet mit einem Fehler: der Brief bleibt offen, alles ist wieder frei (bei einem Erfolg schließt der Brief)
|
||||||
|
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
|
||||||
assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false])
|
assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false])
|
||||||
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }])
|
||||||
} finally { ui.end() }
|
} finally { ui.end() }
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld', async () => {
|
test('Pult (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld', async () => {
|
||||||
const api = scriptedApi()
|
const api = scriptedApi()
|
||||||
const ui = await startList([{ ...unsicher, draft: undefined }], api)
|
const ui = await startList([{ ...unsicher, draft: undefined }], api)
|
||||||
try {
|
try {
|
||||||
@@ -693,7 +770,7 @@ test('PultList (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffne
|
|||||||
|
|
||||||
// Endprüfung P4 (W4): die aufgeklappten Felder schließen mit Esc und mit „Abbrechen“; der Fokus geht an den Knopf zurück, der sie öffnete, der Text bleibt.
|
// Endprüfung P4 (W4): die aufgeklappten Felder schließen mit Esc und mit „Abbrechen“; der Fokus geht an den Knopf zurück, der sie öffnete, der Text bleibt.
|
||||||
// Während einer Anfrage bleiben beide gesperrt (das Feld soll nicht mitten im Senden verschwinden).
|
// Während einer Anfrage bleiben beide gesperrt (das Feld soll nicht mitten im Senden verschwinden).
|
||||||
test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch antworten“, geben den Fokus an den Knopf zurück und behalten den Text', async () => {
|
test('Pult (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch antworten“, geben den Fokus an den Knopf zurück und behalten den Text', async () => {
|
||||||
const f = fakeFocus()
|
const f = fakeFocus()
|
||||||
let finish
|
let finish
|
||||||
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
|
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
|
||||||
@@ -701,16 +778,17 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch a
|
|||||||
try {
|
try {
|
||||||
const opener = f.el('opener')
|
const opener = f.el('opener')
|
||||||
f.found.set('[data-opens="reply"]', opener)
|
f.found.set('[data-opens="reply"]', opener)
|
||||||
assert.equal(clickable(inList(ui.tree()), 'Als Mensch antworten')[0].props['data-opens'], 'reply') // der Auslöser trägt seine Kennung
|
await ui.open()
|
||||||
|
assert.equal(clickable(ui.brief(), 'Als Mensch antworten')[0].props['data-opens'], 'reply') // der Auslöser trägt seine Kennung
|
||||||
assert.ok(!ui.has('Abbrechen'))
|
assert.ok(!ui.has('Abbrechen'))
|
||||||
await ui.click('Als Mensch antworten')
|
await ui.click('Als Mensch antworten')
|
||||||
assert.ok(ui.has('Abbrechen'))
|
assert.ok(ui.has('Abbrechen'))
|
||||||
await ui.type('Text der Antwort', 'Mein Text')
|
await ui.type('Text der Antwort', 'Mein Text')
|
||||||
// eine andere Taste ändert nichts
|
// eine andere Taste ändert nichts
|
||||||
let event = await ui.key('wk-pult-reply', 'a')
|
let event = await ui.key('hk-feld-antwort', 'a')
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false)
|
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false)
|
||||||
// Esc: Feld zu, Fokus auf dem Auslöser, das Ereignis ist behandelt (die Handy-Leiste schließt sonst mit)
|
// Esc: Feld zu, Fokus auf dem Auslöser, das Ereignis ist behandelt (die Handy-Leiste schließt sonst mit)
|
||||||
event = await ui.key('wk-pult-reply')
|
event = await ui.key('hk-feld-antwort')
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 1); assert.equal(f.doc.activeElement, opener)
|
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 1); assert.equal(f.doc.activeElement, opener)
|
||||||
assert.ok(event.defaultPrevented); assert.ok(event.stopped)
|
assert.ok(event.defaultPrevented); assert.ok(event.stopped)
|
||||||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], false)
|
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], false)
|
||||||
@@ -727,7 +805,7 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch a
|
|||||||
await ui.click('Als Mensch antworten')
|
await ui.click('Als Mensch antworten')
|
||||||
await ui.click('Jetzt senden')
|
await ui.click('Jetzt senden')
|
||||||
assert.equal(api.calls.length, 1); assert.deepEqual(ui.disabled('Abbrechen'), [true])
|
assert.equal(api.calls.length, 1); assert.deepEqual(ui.disabled('Abbrechen'), [true])
|
||||||
event = await ui.key('wk-pult-reply')
|
event = await ui.key('hk-feld-antwort')
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false); assert.equal(opener.focusCalls, 2)
|
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false); assert.equal(opener.focusCalls, 2)
|
||||||
await ui.click('Abbrechen')
|
await ui.click('Abbrechen')
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text'])
|
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text'])
|
||||||
@@ -735,7 +813,7 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch a
|
|||||||
} finally { ui.end(); f.restore() }
|
} finally { ui.end(); f.restore() }
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort des Kunden einfügen“ und kehren an dessen Knopf zurück', async () => {
|
test('Pult (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort des Kunden einfügen“ und kehren an dessen Knopf zurück', async () => {
|
||||||
const f = fakeFocus()
|
const f = fakeFocus()
|
||||||
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', 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()
|
const api = scriptedApi()
|
||||||
@@ -743,10 +821,11 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort
|
|||||||
try {
|
try {
|
||||||
const opener = f.el('opener')
|
const opener = f.el('opener')
|
||||||
f.found.set('[data-opens="paste"]', opener)
|
f.found.set('[data-opens="paste"]', opener)
|
||||||
assert.equal(clickable(inList(ui.tree()), 'Antwort des Kunden einfügen')[0].props['data-opens'], 'paste')
|
await ui.open()
|
||||||
|
assert.equal(clickable(ui.brief(), 'Antwort des Kunden einfügen')[0].props['data-opens'], 'paste')
|
||||||
await ui.click('Antwort des Kunden einfügen')
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
await ui.type('Antwort des Kunden', 'Noch nicht gesendet')
|
await ui.type('Antwort des Kunden', 'Noch nicht gesendet')
|
||||||
await ui.key('wk-pult-paste')
|
await ui.key('hk-feld-einfuegen')
|
||||||
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 1)
|
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 1)
|
||||||
await ui.click('Antwort des Kunden einfügen')
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch nicht gesendet']) // der Text bleibt
|
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch nicht gesendet']) // der Text bleibt
|
||||||
@@ -754,17 +833,18 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort
|
|||||||
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0)
|
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0)
|
||||||
// „Abbrechen“ liegt vor dem Absenden (Einfügen bleibt der Hauptknopf)
|
// „Abbrechen“ liegt vor dem Absenden (Einfügen bleibt der Hauptknopf)
|
||||||
await ui.click('Antwort des Kunden einfügen')
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
const names = nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'wk-pult-panel-btns'))[0].children.map(textOf)
|
const names = nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'hk-feld-knoepfe'))[0].children.map(textOf)
|
||||||
assert.deepEqual(names, ['Abbrechen', 'Einfügen'])
|
assert.deepEqual(names, ['Abbrechen', 'Einfügen'])
|
||||||
} finally { ui.end(); f.restore() }
|
} finally { ui.end(); f.restore() }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Endprüfung P4 (W3): ein gesperrter Knopf verliert den Fokus an <body>. Nach der Anfrage gibt die Zeile ihn zurück (an den Knopf, sonst ans Feld, an den
|
// Endprüfung P4 (W3): ein gesperrter Knopf verliert den Fokus an <body>. Nach der Anfrage gibt der Brief ihn zurück (an den Knopf, sonst ans Feld, an den
|
||||||
// Knopf, der das Feld öffnete, oder an die Zeile); verlässt eine Zeile die Liste, geht er an die Meldung oben. Wer den Fokus inzwischen selbst
|
// Knopf, der das Feld öffnete, oder an den Brief); verlässt ein Eintrag die Liste, geht er an die Meldung oben. Wer den Fokus inzwischen selbst
|
||||||
// woanders hat, wird nicht gestört. Der Test spielt den Browser nach: activeElement stellt der Test (Klick: Knopf; Sperre: body).
|
// woanders hat, wird nicht gestört. Der Test spielt den Browser nach: activeElement stellt der Test (Klick: Knopf; Sperre: body).
|
||||||
const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
|
// Der Brief nimmt beim Öffnen selbst den Fokus (erster Knopf): die Tests setzen activeElement deshalb erst nach ui.open().
|
||||||
|
const PANEL_FIELD = '.hk-feld-panel textarea'
|
||||||
|
|
||||||
test('PultList (bedient, W3): nach einem Fehler beim Senden steht der Fokus wieder auf „Jetzt senden“; hat die Person inzwischen selbst gewählt, bleibt er dort', async () => {
|
test('Pult (bedient, W3): nach einem Fehler beim Senden steht der Fokus wieder auf „Jetzt senden“; hat die Person inzwischen selbst gewählt, bleibt er dort', async () => {
|
||||||
for (const moved of [false, true]) {
|
for (const moved of [false, true]) {
|
||||||
const f = fakeFocus()
|
const f = fakeFocus()
|
||||||
let reject
|
let reject
|
||||||
@@ -778,34 +858,34 @@ test('PultList (bedient, W3): nach einem Fehler beim Senden steht der Fokus wied
|
|||||||
const other = f.el('other')
|
const other = f.el('other')
|
||||||
f.doc.activeElement = moved ? other : f.body // der gesperrte Knopf verliert den Fokus (Chromium) – oder die Person ist schon woandershin gegangen
|
f.doc.activeElement = moved ? other : f.body // der gesperrte Knopf verliert den Fokus (Chromium) – oder die Person ist schon woandershin gegangen
|
||||||
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
|
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }]) // die Zeile bleibt
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }]) // der Brief bleibt
|
||||||
if (moved) assert.deepEqual([f.doc.activeElement, submit.focusCalls], [other, 0])
|
if (moved) assert.deepEqual([f.doc.activeElement, submit.focusCalls], [other, 0])
|
||||||
else assert.deepEqual([f.doc.activeElement, submit.focusCalls], [submit, 1])
|
else assert.deepEqual([f.doc.activeElement, submit.focusCalls], [submit, 1])
|
||||||
} finally { ui.end(); f.restore() }
|
} finally { ui.end(); f.restore() }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, W3): ist der Knopf nach der Aktion weg („Ich kümmere mich“), geht der Fokus an die Zeile; sie ist mit tabIndex -1 fokussierbar', async () => {
|
test('Pult (bedient, W3): ist der Knopf nach der Aktion weg („Ich kümmere mich“), geht der Fokus an den Brief; er ist mit tabIndex -1 fokussierbar', async () => {
|
||||||
const f = fakeFocus()
|
const f = fakeFocus()
|
||||||
let finish
|
let finish
|
||||||
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
|
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
|
||||||
const ui = await startList([unsicher], api)
|
const ui = await startList([unsicher], api)
|
||||||
try {
|
try {
|
||||||
const li = nodes(ui.tree(), (n) => n.tag === 'li')[0]
|
await ui.open()
|
||||||
assert.equal(li.props.tabIndex, -1)
|
assert.equal(ui.brief().props.tabIndex, -1)
|
||||||
const claim = f.el('claim')
|
const claim = f.el('claim')
|
||||||
f.doc.activeElement = claim
|
f.doc.activeElement = claim
|
||||||
await ui.click('Ich kümmere mich')
|
await ui.click('Ich kümmere mich')
|
||||||
f.doc.activeElement = f.body
|
f.doc.activeElement = f.body
|
||||||
claim.isConnected = false // nach „übernommen“ gibt es den Knopf nicht mehr
|
claim.isConnected = false // nach „übernommen“ gibt es den Knopf nicht mehr
|
||||||
finish({ item: { $id: 'e1' } }); await settle()
|
finish({ item: { $id: 'e1' } }); await settle()
|
||||||
const row = f.ref('wk-pult-row')
|
const brief = f.ref('hk-brief')
|
||||||
assert.deepEqual([row.focusCalls, f.doc.activeElement], [1, row])
|
assert.deepEqual([brief.focusCalls, f.doc.activeElement], [1, brief])
|
||||||
assert.equal(claim.focusCalls, 0)
|
assert.equal(claim.focusCalls, 0)
|
||||||
} finally { ui.end(); f.restore() }
|
} finally { ui.end(); f.restore() }
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus an „Antwort des Kunden einfügen“; nach einem Fehler bleibt er im Feld', async () => {
|
test('Pult (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus an „Antwort des Kunden einfügen“; nach einem Fehler bleibt er im Feld', async () => {
|
||||||
const f = fakeFocus()
|
const f = fakeFocus()
|
||||||
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||||||
let reject
|
let reject
|
||||||
@@ -823,7 +903,7 @@ test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus
|
|||||||
f.doc.activeElement = f.body
|
f.doc.activeElement = f.body
|
||||||
reject(failure('Dieser Kontakt ist gesperrt.', 409)); await settle()
|
reject(failure('Dieser Kontakt ist gesperrt.', 409)); await settle()
|
||||||
assert.deepEqual([f.doc.activeElement, insert.focusCalls], [insert, 1])
|
assert.deepEqual([f.doc.activeElement, insert.focusCalls], [insert, 1])
|
||||||
// Erfolg: das Feld klappt zu (Textfeld und Knopf „Einfügen“ sind weg), der Fokus geht an „Antwort des Kunden einfügen“ (vor der Zeile)
|
// Erfolg: das Feld klappt zu (Textfeld und Knopf „Einfügen“ sind weg), der Fokus geht an „Antwort des Kunden einfügen“ (vor dem Brief)
|
||||||
f.found.delete(PANEL_FIELD)
|
f.found.delete(PANEL_FIELD)
|
||||||
f.doc.activeElement = insert
|
f.doc.activeElement = insert
|
||||||
await ui.click('Einfügen')
|
await ui.click('Einfügen')
|
||||||
@@ -831,12 +911,12 @@ test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus
|
|||||||
insert.isConnected = false
|
insert.isConnected = false
|
||||||
finish({ status: 'neu' }); await settle()
|
finish({ status: 'neu' }); await settle()
|
||||||
assert.deepEqual([opener.focusCalls, f.doc.activeElement], [1, opener])
|
assert.deepEqual([opener.focusCalls, f.doc.activeElement], [1, opener])
|
||||||
assert.equal(field.focusCalls, 0); assert.equal(f.ref('wk-pult-row').focusCalls, 0)
|
assert.equal(field.focusCalls, 0); assert.equal(f.ref('hk-brief').focusCalls, 0)
|
||||||
// und bleibt das Feld offen, aber der Knopf ist weg, nimmt das Textfeld den Fokus (vor dem Auslöser)
|
// und bleibt das Feld offen, aber der Knopf ist weg, nimmt das Textfeld den Fokus (vor dem Auslöser)
|
||||||
} finally { ui.end(); f.restore() }
|
} finally { ui.end(); f.restore() }
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, W3): verlässt die Zeile die Liste, geht der Fokus an die Meldung oben (auch bei gleichem Text); sie ist mit tabIndex -1 fokussierbar', async () => {
|
test('Pult (bedient, W3): verlässt ein Eintrag die Liste, geht der Fokus an die Meldung oben (auch bei gleichem Text); sie ist mit tabIndex -1 fokussierbar', async () => {
|
||||||
const f = fakeFocus()
|
const f = fakeFocus()
|
||||||
const zweite = { ...unsicher, $id: 'e2' } // gleicher Titel: dieselbe Meldung zweimal
|
const zweite = { ...unsicher, $id: 'e2' } // gleicher Titel: dieselbe Meldung zweimal
|
||||||
let finish
|
let finish
|
||||||
@@ -844,27 +924,31 @@ test('PultList (bedient, W3): verlässt die Zeile die Liste, geht der Fokus an d
|
|||||||
const ui = await startList([unsicher, zweite], api)
|
const ui = await startList([unsicher, zweite], api)
|
||||||
try {
|
try {
|
||||||
// der Knopf, den die Anfrage gerade sperrt (disabled): der Browser lässt ihn erst beim nächsten Takt los, dann liegt der Fokus auf <body>
|
// der Knopf, den die Anfrage gerade sperrt (disabled): der Browser lässt ihn erst beim nächsten Takt los, dann liegt der Fokus auf <body>
|
||||||
|
await ui.open(0)
|
||||||
const done0 = f.el('done0', { disabled: true }); f.doc.activeElement = done0
|
const done0 = f.el('done0', { disabled: true }); f.doc.activeElement = done0
|
||||||
await ui.click('Erledigt', 0)
|
await ui.click('Erledigt')
|
||||||
const status = f.ref('msg')
|
const status = f.ref('hk-hinweis')
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ erledigt.', role: 'status' }])
|
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ erledigt.', role: 'status' }])
|
||||||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'msg'))[0].props.tabIndex, -1)
|
assert.equal(ui.brief(), null) // der Brief schließt, der Eintrag verlässt die Liste
|
||||||
|
assert.equal(nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'hk-hinweis'))[0].props.tabIndex, -1)
|
||||||
assert.deepEqual([status.focusCalls, f.doc.activeElement], [1, status])
|
assert.deepEqual([status.focusCalls, f.doc.activeElement], [1, status])
|
||||||
assert.equal(done0.focusCalls, 0)
|
assert.equal(done0.focusCalls, 0)
|
||||||
// zweite Zeile, gleiche Meldung: wieder auf die Meldung
|
// zweiter Eintrag, gleiche Meldung: wieder auf die Meldung
|
||||||
|
await ui.open(1)
|
||||||
f.doc.activeElement = f.el('done1', { disabled: true })
|
f.doc.activeElement = f.el('done1', { disabled: true })
|
||||||
await ui.click('Erledigt', 1)
|
await ui.click('Erledigt')
|
||||||
assert.equal(status.focusCalls, 2); assert.equal(f.doc.activeElement, status)
|
assert.equal(status.focusCalls, 2); assert.equal(f.doc.activeElement, status)
|
||||||
// hat die Person den Fokus während der Anfrage selbst woanders hingesetzt, bleibt er dort
|
// hat die Person den Fokus während der Anfrage selbst woanders hingesetzt, bleibt er dort
|
||||||
|
await ui.open(0)
|
||||||
f.doc.activeElement = f.el('done2', { disabled: true })
|
f.doc.activeElement = f.el('done2', { disabled: true })
|
||||||
await ui.click('Erledigt', 0)
|
await ui.click('Erledigt')
|
||||||
const other = f.el('other'); f.doc.activeElement = other
|
const other = f.el('other'); f.doc.activeElement = other
|
||||||
finish({}); await settle()
|
finish({}); await settle()
|
||||||
assert.deepEqual([status.focusCalls, f.doc.activeElement], [2, other])
|
assert.deepEqual([status.focusCalls, f.doc.activeElement], [2, other])
|
||||||
} finally { ui.end(); f.restore() }
|
} finally { ui.end(); f.restore() }
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, W3): ohne document (Server, Node) und ohne Fokus vor dem Klick passiert nichts', async () => {
|
test('Pult (bedient, W3): ohne document (Server, Node) und ohne Fokus vor dem Klick passiert nichts', async () => {
|
||||||
const api = scriptedApi()
|
const api = scriptedApi()
|
||||||
const ui = await startList([unsicher], api) // kein document, kein fakeRef: refs bleiben null
|
const ui = await startList([unsicher], api) // kein document, kein fakeRef: refs bleiben null
|
||||||
try {
|
try {
|
||||||
@@ -876,21 +960,24 @@ test('PultList (bedient, W3): ohne document (Server, Node) und ohne Fokus vor de
|
|||||||
const f = fakeFocus()
|
const f = fakeFocus()
|
||||||
const ui2 = await startList([unsicher], scriptedApi())
|
const ui2 = await startList([unsicher], scriptedApi())
|
||||||
try {
|
try {
|
||||||
|
await ui2.open()
|
||||||
|
f.doc.activeElement = f.body // der Brief hat beim Öffnen den Fokus genommen; ein Klick in Safari ließe ihn bei <body>
|
||||||
await ui2.click('Ich kümmere mich')
|
await ui2.click('Ich kümmere mich')
|
||||||
assert.deepEqual([f.ref('wk-pult-row').focusCalls, f.doc.activeElement], [0, f.body])
|
assert.deepEqual([f.ref('hk-brief').focusCalls, f.doc.activeElement], [0, f.body])
|
||||||
await ui2.click('Erledigt')
|
await ui2.click('Erledigt')
|
||||||
assert.ok(ui2.text().includes('erledigt.'))
|
assert.ok(ui2.text().includes('erledigt.'))
|
||||||
assert.deepEqual([f.ref('msg').focusCalls, f.doc.activeElement], [0, f.body])
|
assert.deepEqual([f.ref('hk-hinweis').focusCalls, f.doc.activeElement], [0, f.body])
|
||||||
} finally { ui2.end(); f.restore() }
|
} finally { ui2.end(); f.restore() }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Endprüfung P4 (W2): „Vorschau bauen“ startet einen Opus-Lauf und lässt sich in der Oberfläche nicht zurücknehmen: wie Freigeben und Verwerfen
|
// Endprüfung P4 (W2): „Vorschau bauen“ startet einen Opus-Lauf und lässt sich in der Oberfläche nicht zurücknehmen: wie Freigeben und Verwerfen
|
||||||
// fragt der Knopf zuerst nach (4 s). Ein Fehlklick (Handy: der Knopf liegt in voller Breite zwischen anderen) startet keinen Bau mehr.
|
// fragt der Knopf zuerst nach (4 s). Ein Fehlklick (Handy: der Knopf liegt in voller Breite zwischen anderen) startet keinen Bau mehr.
|
||||||
test('PultList (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (4 s, Hinweis), zweiter startet den Bau; Fehler und Erfolg wie bisher', async () => {
|
test('Pult (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (4 s, Hinweis), zweiter startet den Bau; Fehler und Erfolg wie bisher', async () => {
|
||||||
const api = scriptedApi(failure('Für diesen Interessenten gibt es schon eine Vorschau.', 409), Promise.resolve({ item: { $id: 'e1' } }))
|
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)
|
const ui = await startList([{ ...unsicher, draft: undefined }], api)
|
||||||
try {
|
try {
|
||||||
// erster Klick: Rückfrage und Hinweis zu Kosten und Wirkung, nichts geht hinaus; die Rückfrage verfällt nach 4 s
|
// erster Klick: Rückfrage und Hinweis zu Kosten und Wirkung, nichts geht hinaus; die Rückfrage verfällt nach 4 s
|
||||||
|
await ui.open()
|
||||||
assert.ok(!ui.text().includes('Opus'))
|
assert.ok(!ui.text().includes('Opus'))
|
||||||
await ui.click('Vorschau bauen')
|
await ui.click('Vorschau bauen')
|
||||||
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich bauen?')); assert.ok(!ui.has('Vorschau bauen'))
|
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich bauen?')); assert.ok(!ui.has('Vorschau bauen'))
|
||||||
@@ -911,7 +998,7 @@ test('PultList (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (
|
|||||||
} finally { ui.end() }
|
} finally { ui.end() }
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben lösen einander ab; Verwerfen sendet nichts ohne zweiten Klick', async () => {
|
test('Pult (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben lösen einander ab; Verwerfen sendet nichts ohne zweiten Klick', async () => {
|
||||||
const api = scriptedApi()
|
const api = scriptedApi()
|
||||||
const ui = await startList([unsicher], api)
|
const ui = await startList([unsicher], api)
|
||||||
try {
|
try {
|
||||||
@@ -924,12 +1011,13 @@ test('PultList (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben
|
|||||||
} finally { ui.end() }
|
} 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 () => {
|
test('Pult (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 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 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)
|
const ui = await startList([freigabe], api)
|
||||||
try {
|
try {
|
||||||
// der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug
|
// der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug
|
||||||
|
await ui.open()
|
||||||
const link = ui.link('Vorschau ansehen')
|
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.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'))
|
assert.ok(!ui.has('Anzeige öffnen')); assert.ok(!ui.has('Erledigt'))
|
||||||
@@ -958,12 +1046,13 @@ test('PultList (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Endprüfung P4 (W5): ohne gültige Vorschau-Adresse lässt sich nicht freigeben (auch nicht über die Rückfrage), mit gültiger Adresse wie bisher
|
// Endprüfung P4 (W5): ohne gültige Vorschau-Adresse lässt sich nicht freigeben (auch nicht über die Rückfrage), mit gültiger Adresse wie bisher
|
||||||
test('PultList (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorschau-Adresse gesperrt – kein Klick, keine Rückfrage, keine Aktion', async () => {
|
test('Pult (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorschau-Adresse gesperrt – kein Klick, keine Rückfrage, keine Aktion', async () => {
|
||||||
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.' }
|
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.' }
|
||||||
for (const sourceUrl of ['http://entwurf.project.webklar.com/AAA/', 'https://fremd.example/AAA/', '']) {
|
for (const sourceUrl of ['http://entwurf.project.webklar.com/AAA/', 'https://fremd.example/AAA/', '']) {
|
||||||
const api = scriptedApi()
|
const api = scriptedApi()
|
||||||
const ui = await startList([{ ...freigabe, sourceUrl }], api)
|
const ui = await startList([{ ...freigabe, sourceUrl }], api)
|
||||||
try {
|
try {
|
||||||
|
await ui.open()
|
||||||
assert.deepEqual(ui.disabled('Freigeben & senden'), [true], sourceUrl)
|
assert.deepEqual(ui.disabled('Freigeben & senden'), [true], sourceUrl)
|
||||||
assert.ok(ui.text().includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich')); assert.ok(!ui.has('Vorschau ansehen'))
|
assert.ok(ui.text().includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich')); assert.ok(!ui.has('Vorschau ansehen'))
|
||||||
await ui.click('Freigeben & senden')
|
await ui.click('Freigeben & senden')
|
||||||
@@ -977,13 +1066,14 @@ test('PultList (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorscha
|
|||||||
const api = scriptedApi()
|
const api = scriptedApi()
|
||||||
const ui = await startList([{ ...freigabe, sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }], api)
|
const ui = await startList([{ ...freigabe, sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }], api)
|
||||||
try {
|
try {
|
||||||
|
await ui.open()
|
||||||
assert.deepEqual(ui.disabled('Freigeben & senden'), [false]); assert.ok(!ui.text().includes('Keine gültige Vorschau-Adresse'))
|
assert.deepEqual(ui.disabled('Freigeben & senden'), [false]); assert.ok(!ui.text().includes('Keine gültige Vorschau-Adresse'))
|
||||||
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?'))
|
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?'))
|
||||||
await ui.click('Wirklich freigeben?'); assert.deepEqual(api.calls, [['f1', 'approve-preview']])
|
await ui.click('Wirklich freigeben?'); assert.deepEqual(api.calls, [['f1', 'approve-preview']])
|
||||||
} finally { ui.end() }
|
} 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 () => {
|
test('Pult (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 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 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)
|
const ui = await startList([plattform], api)
|
||||||
@@ -996,7 +1086,7 @@ test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres
|
|||||||
await ui.click('Einfügen')
|
await ui.click('Einfügen')
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]); assert.equal(api.calls.length, 0)
|
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
|
// einfügen: getrimmter Text an die Id des Eintrags; danach ist das Feld geleert und zu, der Brief meldet das Einsortieren
|
||||||
await ui.type('Antwort des Kunden', ' Danke, das passt. ')
|
await ui.type('Antwort des Kunden', ' Danke, das passt. ')
|
||||||
await ui.click('Einfügen')
|
await ui.click('Einfügen')
|
||||||
assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]])
|
assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]])
|
||||||
@@ -1010,13 +1100,14 @@ test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres
|
|||||||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }])
|
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }])
|
||||||
assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
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)
|
// Dienstfehler: Satz als Fehler, der Text bleibt stehen (auch wenn der Brief schließt, der Reiter gewechselt wird und der Eintrag verschwindet)
|
||||||
await ui.click('Antwort des Kunden einfügen')
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
await ui.type('Antwort des Kunden', 'Noch eine Antwort')
|
await ui.type('Antwort des Kunden', 'Noch eine Antwort')
|
||||||
await ui.click('Einfügen')
|
await ui.click('Einfügen')
|
||||||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }])
|
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }])
|
||||||
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
||||||
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
await ui.close()
|
||||||
|
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(envelopes(ui.tree()).length, 0)
|
||||||
await ui.tab('Wartet')
|
await ui.tab('Wartet')
|
||||||
await ui.click('Antwort des Kunden einfügen')
|
await ui.click('Antwort des Kunden einfügen')
|
||||||
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
||||||
@@ -1025,40 +1116,52 @@ test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres
|
|||||||
} finally { ui.end() }
|
} 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 () => {
|
test('Pult (bedient, P4-T32): ein Text von „Als Mensch antworten“ überlebt Schließen und den Wechsel des Reiters; nach dem Absenden ist er weg', async () => {
|
||||||
const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } }))
|
const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } }))
|
||||||
const ui = await startList([unsicher], api)
|
const ui = await startList([unsicher], api)
|
||||||
try {
|
try {
|
||||||
await ui.click('Als Mensch antworten')
|
await ui.click('Als Mensch antworten')
|
||||||
await ui.type('Text der Antwort', 'Mein eigener Text')
|
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.close()
|
||||||
|
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Text der Antwort'), []) // der Eintrag ist weg
|
||||||
await ui.tab('Wartet')
|
await ui.tab('Wartet')
|
||||||
await ui.click('Als Mensch antworten')
|
await ui.click('Als Mensch antworten')
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text'])
|
assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text'])
|
||||||
await ui.click('Jetzt senden')
|
await ui.click('Jetzt senden')
|
||||||
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]])
|
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]])
|
||||||
|
await ui.click('Als Mensch antworten')
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
||||||
} finally { ui.end() }
|
} finally { ui.end() }
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PultList (bedient, P4-T32): mehrere Einträge desselben Interessenten – Felder, Texte und Aktionen hängen an der Eintrags-Id', async () => {
|
test('Pult (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 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 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 api = scriptedApi(Promise.resolve({ item: { $id: 'e3' } }), Promise.resolve({ status: 'neu' }))
|
||||||
const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api)
|
const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api)
|
||||||
try {
|
try {
|
||||||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'li').length, 3)
|
assert.equal(envelopes(ui.tree()).length, 3)
|
||||||
await ui.click('Als Mensch antworten', 0)
|
await ui.click('Als Mensch antworten', 0)
|
||||||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur Zeile 1 ist aufgeklappt
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur der Brief des ersten Eintrags ist offen
|
||||||
|
await ui.type('Text der Antwort', 'Text eins')
|
||||||
await ui.click('Als Mensch antworten', 1)
|
await ui.click('Als Mensch antworten', 1)
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf drei']) // der Brief zeigt nur seinen Eintrag
|
||||||
await ui.type('Text der Antwort: Zweite Rückfrage', 'Text drei')
|
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
|
// die Texte stören sich nicht: nach dem Wechsel zurück steht in jedem Eintrag sein eigener Text
|
||||||
// gesendet wird der Text der Zeile, deren Knopf gedrückt wurde, an die Id dieses Eintrags
|
await ui.click('Als Mensch antworten', 0)
|
||||||
await ui.click('Jetzt senden', 1)
|
assert.deepEqual(ui.value('Text der Antwort'), ['Text eins'])
|
||||||
|
await ui.click('Als Mensch antworten', 1)
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Text drei'])
|
||||||
|
// gesendet wird der Text des Eintrags, dessen Brief offen ist, an die Id dieses Eintrags
|
||||||
|
await ui.click('Jetzt senden')
|
||||||
assert.deepEqual(api.calls, [['e3', 'reply', { text: 'Text drei' }]])
|
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
|
assert.equal(ui.brief(), null)
|
||||||
// Einfügen an der dritten Zeile: nur dort, mit der Id des Eintrags k9
|
await ui.click('Als Mensch antworten', 1)
|
||||||
await ui.click('Antwort des Kunden einfügen')
|
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf drei']) // e3 ist abgeschlossen (Text zurückgesetzt)
|
||||||
|
await ui.click('Als Mensch antworten', 0)
|
||||||
|
assert.deepEqual(ui.value('Text der Antwort'), ['Text eins']) // e1 unberührt
|
||||||
|
// Einfügen am dritten Eintrag: nur dort, mit der Id des Eintrags k9
|
||||||
|
await ui.click('Antwort des Kunden einfügen', 2)
|
||||||
await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern')
|
await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern')
|
||||||
await ui.click('Einfügen')
|
await ui.click('Einfügen')
|
||||||
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
|
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
|
||||||
|
|||||||
Reference in new issue
Block a user