Hustler-Stufe: P4-Aktionen im Pult erst ab Stufe 4 (rowModel-Parameter stufe, PultList reicht ihn durch)

Unterhalb von Stufe 4 sind canReply, canPaste, canApprovePreview und canStartBuild immer false: Antwort des Kunden einfügen, Als Mensch antworten / Jetzt senden, Freigeben & senden
und Vorschau bauen verschwinden samt Feldern und Rückfragen, die Aktionsgruppe schließt sich ohne Lücke. P3-Aktionen (übernehmen, Kopieren & öffnen, als gesendet markieren, verwerfen,
erledigt, E-Mail schreiben) bleiben. Bestehende P4-Tests laufen ausdrücklich auf Stufe 6; neue Tests prüfen Stufe 3 gerendert und bedient (Reihenfolge der Knöpfe, kein Feld, Esc, Verwerfen mit Rückfrage).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-22 10:36:44 +00:00
1 parent 7cd74a23d2
commit 5e48fbf264
5 files changed
+207 -15

No files matched your search

+5 -3
View File
@@ -7,7 +7,8 @@
//
// 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), paused (Hustler angehalten: die Szene steht still, Klasse hk-steht), onBack() („◂ Hochhaus“). workers (Hustler-Tabelle) reicht die Seite durch, das Pult
// braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler.
// braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler. stufe (Standard HUSTLER_STUFE, hustlerStufe.js) geht an rowModel: unterhalb der Antwort-Stufe (4) bietet kein
// Brief die P4-Aktionen an (Antwort einfügen, als Mensch antworten, Vorschau freigeben, Vorschau bauen).
// Laufende Aktionen (T13 P1): welche Einträge gerade eine Anfrage an den Dienst laufen haben, merkt PultList (laufRef, nach Eintrags-Id), nicht der Brief: wer den Brief
// während der Anfrage schließt und denselben Eintrag wieder öffnet, findet alle Aktionsknöpfe gesperrt und kann keine zweite Anfrage auslösen (sonst fügte „Antwort
// einfügen“ doppelt ein); sobald die Anfrage endet, sind sie frei.
@@ -17,6 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useEmployees } from '../../hooks/useEmployees'
import { usePultFeed } from '../../hooks/usePultFeed'
import { reiterZahl, wartetZahl, botenLaufen } from '../../lib/hustlerPult'
import { HUSTLER_STUFE } from '../../lib/hustlerStufe'
import { activeControl, restoreFocus } from '../../lib/workersFocus'
import { PULT_FILTERS, rowModel } from '../../lib/workersPult'
import Brief from './pult/Brief'
@@ -29,7 +31,7 @@ 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
const browserDocument = () => (typeof document === 'undefined' ? null : document)
export default function PultList({ api, me, kpis, paused = false, onBack }) {
export default function PultList({ api, me, kpis, paused = false, onBack, stufe = HUSTLER_STUFE }) {
const [filterId, setFilterId] = useState('offen')
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
@@ -123,7 +125,7 @@ export default function PultList({ api, me, kpis, paused = false, onBack }) {
return true
}, [])
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees, stufe }) }))
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(() => {
+9 -5
View File
@@ -4,6 +4,7 @@
// Plattform-Antworten schickt der Mensch selbst ab (Plattform) und markiert sie danach als gesendet; Mail-Antworten und die Freigabe
// einer Vorschau gehen über den Dienst (api.pultAction), der sie durch das Sende-Tor führt.
import { HUSTLER_STUFE, antwortenOffen } from './hustlerStufe'
import { DOWN_TEXT, SLOW_PULT_ACTIONS, UNCERTAIN_STATUS } from './workersApi'
import { roleOf } from './workersRoster'
@@ -179,8 +180,10 @@ function doneByPhrase(by, employees) {
* Zeilenmodell eines Pult-Eintrags (GET /pult: Eintrag plus `prospect`). me = { id, name } des Angemeldeten, employees = Mitarbeiterliste.
* Rechte nach Status (offen, in_arbeit, veraltet sind bearbeitbar) und Art (nur Antwort-Arten sind sendbar); die Zeile sperrt
* nichts wegen eines Kollegen, der übernommen hat: der Dienst entscheidet (409 mit deutschem Satz).
* stufe (Standard HUSTLER_STUFE, hustlerStufe.js): unterhalb der Antwort-Stufe (4, Dienst P4) sind die vier P4-Aktionen canReply, canPaste, canApprovePreview und
* canStartBuild immer false, denn deren Endpunkte gibt es im Dienst noch nicht; die P3-Aktionen (übernehmen, gesendet, verwerfen, erledigt, Mail-Link) bleiben.
*/
export function rowModel(item, { me, nowMs, employees }) {
export function rowModel(item, { me, nowMs, employees, stufe = HUSTLER_STUFE }) {
const editable = BEARBEITBAR.includes(item.status)
const sendable = SENDABLE_KINDS.includes(item.kind)
const prospect = item.prospect ?? null
@@ -197,6 +200,7 @@ export function rowModel(item, { me, nowMs, employees }) {
// P4: Antworten, Freigeben und Bauen nur an einem bearbeitbaren Eintrag, den kein anderer hält (Einfügen kennt diese Sperre nicht, siehe canPaste)
const free = editable && !claimedByOther
const freigabe = item.kind === 'vorschau_freigabe'
const p4 = antwortenOffen(stufe) // ohne P4 im Dienst bietet die Zeile keine der vier neuen Aktionen an
const draft = typeof item.draft === 'string' ? item.draft : ''
let mail = ''
@@ -228,14 +232,14 @@ export function rowModel(item, { me, nowMs, employees }) {
// Erledigt würde eine Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: dort bleibt nur Freigeben oder Verwerfen (D20)
canDone: !sendable && editable && !freigabe,
// Mail-Antwort als Mensch: braucht einen Interessenten mit Adresse („Mail ohne Faden“ und Plattform-Interessenten haben keinen Mail-Weg)
canReply: REPLY_KINDS.includes(item.kind) && free && prospect !== null && filled(prospect.email),
canApprovePreview: freigabe && free,
canReply: p4 && REPLY_KINDS.includes(item.kind) && free && prospect !== null && filled(prospect.email),
canApprovePreview: p4 && freigabe && free,
// Bau starten wie ein „Ja“: Eintrag „unsicher“ mit Interessent. Ein toter Interessent (gesperrt, verloren, Deal) bekommt den Knopf nicht; ohne
// Kurzfassung (prospect null) oder ohne Status entscheidet der Dienst (409 mit deutschem Satz, auch bei einer schon vorhandenen Vorschau)
canStartBuild: item.kind === 'unsicher' && free && filled(item.prospectId) && !(prospect !== null && DEAD_PROSPECT.includes(prospect.status)),
canStartBuild: p4 && item.kind === 'unsicher' && free && filled(item.prospectId) && !(prospect !== null && DEAD_PROSPECT.includes(prospect.status)),
// Die Rückmeldung des Kunden kommt meist erst, nachdem jemand Claras Entwurf auf der Plattform gesendet hat: auch bei gesendet,
// erledigt oder von einem Kollegen übernommen. Nur ein verworfener Eintrag nimmt nichts mehr an.
canPaste: PASTE_KINDS.includes(item.kind) && item.status !== 'verworfen' && prospect !== null,
canPaste: p4 && PASTE_KINDS.includes(item.kind) && item.status !== 'verworfen' && prospect !== null,
claimedByMe,
claimedByOther,
claimedByLabel,
+3 -2
View File
@@ -25,7 +25,8 @@ const NOW = Date.now() // die Bauteile rechnen mit der Uhr des Rechners: Alter u
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 })
// Stufen-Sperre (hustlerStufe.js): diese Datei prüft das volle Pult (Stufe 6, P4-Aktionen offen, Verhalten wie vor der Sperre); Stufe 3 prüfen workersPultUi.test.mjs und hustlerStufe.test.mjs
const rowOf = (item) => rowModel(item, { me: ME, nowMs: NOW, employees: EMPLOYEES, stufe: 6 })
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 })
// ---------------------------------------------------------------------------------------------------------------
@@ -209,7 +210,7 @@ async function startPult(items, { api = { pultAction: async () => ({}) }, kpis,
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, paused }))
const mounted = __mount(jsx(PultList, { api, me: ME, kpis, onBack, paused, stufe: 6 }))
const view = {
timers, refreshes,
tree: () => mounted.tree(),
+76 -1
View File
@@ -5,8 +5,13 @@ import { loadModule } from './load.mjs'
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
const {
PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, REPLY_KINDS, PASTE_KINDS, REPLY_TEXT_MAX, UNCERTAIN_TEXT,
staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel, copyAndOpen, validateReplyText, charCount, actionErrorText, pasteDoneText,
staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel: rowModelNachStufe, copyAndOpen, validateReplyText, charCount, actionErrorText, pasteDoneText,
} = await loadModule('src/lib/workersPult.js')
const { HUSTLER_STUFE } = await loadModule('src/lib/hustlerStufe.js')
// Stufen-Sperre (hustlerStufe.js): rowModel bietet die P4-Aktionen erst ab Stufe 4 an. Die Zusicherungen dieser Datei gelten für das volle Pult und laufen deshalb auf
// Stufe 6 (P4 und Keller offen, Verhalten wie vor der Sperre). Was unterhalb von Stufe 4 gilt, prüft hustlerStufe.test.mjs.
const rowModel = (item, ctx) => rowModelNachStufe(item, { stufe: 6, ...ctx })
const NOW = Date.parse('2026-10-19T09:00:00Z')
const MIN = 60 * 1000
@@ -462,3 +467,73 @@ test('pasteDoneText: neu und fortgesetzt melden das Einsortieren, doppelt meldet
assert.equal(pasteDoneText({ status: 'doppelt' }), 'Diese Antwort war schon eingefügt.')
for (const odd of [undefined, null, {}, 'doppelt', { status: 7 }]) assert.equal(pasteDoneText(odd), 'Eingefügt. Nora sortiert die Antwort ein.', String(odd))
})
// ---------- Stufen-Sperre (hustlerStufe.js): rowModel auf Stufe 3 und Stufe 6 ----------
const ME = { id: 'u1', name: 'Justin' }
const PROSPEKT = { $id: 'p1', name: 'Verein', email: 'x@verein.example', status: 'neu' }
const eintrag = (extra) => ({ $id: 'e1', kind: 'plattform_antwort', status: 'offen', title: 'Titel', draft: 'Hallo', createdAt: '2026-10-19T08:00:00Z', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospectId: 'p1', prospect: PROSPEKT, ...extra })
const zeile = (item, s) => rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: s })
const P4 = ['canReply', 'canPaste', 'canApprovePreview', 'canStartBuild']
const P3 = ['canClaim', 'canSend', 'canDiscard', 'canDone']
const VORSCHAU = 'https://entwurf.project.webklar.com/verein'
test('rowModel auf Stufe 1 bis 3: canReply, canPaste, canApprovePreview und canStartBuild sind für jede Art und jeden Status false', () => {
const kinds = Object.keys(PULT_KIND_LABELS)
const states = ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'verworfen', 'erledigt']
for (const s of [1, 2, 3]) {
for (const kind of kinds) {
for (const status of states) {
for (const extra of [{}, { claimedBy: 'mensch:u1' }, { claimedBy: 'mensch:u2' }, { prospect: null }]) {
const item = eintrag({ kind, status, sourceUrl: kind === 'vorschau_freigabe' ? VORSCHAU : undefined, ...extra })
const m = zeile(item, s)
for (const key of P4) assert.equal(m[key], false, `Stufe ${s} ${kind}/${status} ${JSON.stringify(extra)}: ${key}`)
}
}
}
}
})
test('rowModel auf Stufe 4, 6 und 9: die P4-Aktionen erscheinen wie vor der Sperre', () => {
for (const s of [4, 6, 9]) {
assert.equal(zeile(eintrag({ kind: 'mail_antwort' }), s).canReply, true, `Stufe ${s}`)
assert.equal(zeile(eintrag({ kind: 'unsicher' }), s).canReply, true)
assert.equal(zeile(eintrag({ kind: 'plattform_antwort', status: 'gesendet' }), s).canPaste, true)
assert.equal(zeile(eintrag({ kind: 'plattform_antwort', status: 'verworfen' }), s).canPaste, false) // die Regeln selbst bleiben
assert.equal(zeile(eintrag({ kind: 'vorschau_freigabe', sourceUrl: VORSCHAU }), s).canApprovePreview, true)
assert.equal(zeile(eintrag({ kind: 'unsicher' }), s).canStartBuild, true)
assert.equal(zeile(eintrag({ kind: 'unsicher', prospect: { ...PROSPEKT, status: 'gesperrt' } }), s).canStartBuild, false)
assert.equal(zeile(eintrag({ kind: 'mail_antwort', claimedBy: 'mensch:u2', status: 'in_arbeit' }), s).canReply, false) // von einem Kollegen übernommen
}
})
test('rowModel: die P3-Aktionen sind auf Stufe 3 und 6 gleich; die Stufe ändert nur die vier P4-Felder', () => {
const fixtures = []
for (const kind of Object.keys(PULT_KIND_LABELS)) {
for (const status of ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'verworfen', 'erledigt']) fixtures.push(eintrag({ kind, status, sourceUrl: kind === 'vorschau_freigabe' ? VORSCHAU : undefined }))
}
fixtures.push(eintrag({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), eintrag({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), eintrag({ kind: 'mail_antwort', sourceUrl: '' }))
for (const item of fixtures) {
const gesperrt = zeile(item, 3); const offen = zeile(item, 6)
for (const key of P3) assert.equal(gesperrt[key], offen[key], `${item.kind}/${item.status}: ${key}`)
const ohneP4 = (m) => Object.fromEntries(Object.entries(m).filter(([k]) => !P4.includes(k)))
assert.deepEqual(ohneP4(gesperrt), ohneP4(offen), `${item.kind}/${item.status}`)
}
// und konkret die P3-Aktionen auf Stufe 3
const plattform = zeile(eintrag({}), 3)
assert.deepEqual([plattform.canClaim, plattform.canSend, plattform.canDiscard, plattform.canDone], [true, true, true, false])
assert.equal(plattform.sourceHref, 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3'); assert.equal(plattform.copyText, 'Hallo')
const mail = zeile(eintrag({ kind: 'mail_antwort', sourceUrl: '' }), 3)
assert.match(mail.mailtoHref, /^mailto:x@verein\.example\?/); assert.equal(mail.canSend, true)
const unsicher = zeile(eintrag({ kind: 'unsicher' }), 3)
assert.deepEqual([unsicher.canDone, unsicher.canDiscard, unsicher.canClaim], [true, true, true])
const freigabe = zeile(eintrag({ kind: 'vorschau_freigabe', sourceUrl: VORSCHAU }), 3)
assert.deepEqual([freigabe.canClaim, freigabe.canDiscard, freigabe.canDone, freigabe.canSend], [true, true, false, false])
})
test('rowModel ohne Stufe gilt HUSTLER_STUFE (die Stufe der Produktion)', () => {
const item = eintrag({ kind: 'mail_antwort' })
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [] }).canReply, HUSTLER_STUFE >= 4)
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: undefined }).canReply, HUSTLER_STUFE >= 4)
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: 'x' }).canReply, HUSTLER_STUFE >= 4)
})
+114 -4
View File
@@ -248,9 +248,11 @@ async function loadList() {
"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 })',
// Stufen-Sperre (hustlerStufe.js): die Zusicherungen dieser Datei gelten für das volle Pult (Stufe 6, P4-Aktionen offen, Verhalten wie vor der Sperre);
// die Tests „Stufe 3“ am Ende der Datei reichen stufe: 3 durch
'export const render = (props) => renderToStaticMarkup(createElement(PultList, { stufe: 6, ...props }))',
'export const renderBrief = (item, { me, nowMs, employees, text, fields, stufe = 6 } = {}) => {',
' const row = rowModel(item, { me, nowMs, employees, stufe })',
' return renderToStaticMarkup(createElement(Brief, { api: {}, item, row, text: text ?? row.copyText, fields: fields ?? {}, onText() {}, onField() {}, refresh: async () => {}, onFinished() {}, onClose() {}, onDetails() {}, onSpaet() {} }))',
'}',
].join('\n'),
@@ -571,7 +573,7 @@ async function startList(items, api, props = {}) {
}
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } })
const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' }, ...props }))
const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' }, stufe: 6, ...props })) // Stufe 6 = volles Pult; Stufe 3 siehe die Tests am Ende der Datei
let current = null // Nummer des Umschlags, dessen Brief offen ist
const view = {
refreshes, timers, opened,
@@ -1269,3 +1271,111 @@ test('Pult (bedient, P4-T32): mehrere Einträge desselben Interessenten – Feld
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
} finally { ui.end() }
})
// ---- Stufen-Sperre (hustlerStufe.js): Stufe 3 = Stand der Produktion, der Dienst kann P3, aber noch nicht P4. Das Pult bietet dann keine der vier P4-Aktionen an ----
// ---- (Antwort des Kunden einfügen, Als Mensch antworten / Jetzt senden, Vorschau freigeben, Vorschau bauen); die P3-Aktionen bleiben, ohne Loch und ohne Fokusfalle. ----
const P4_TEXTE = ['Antwort des Kunden einfügen', 'Als Mensch antworten', 'Jetzt senden', 'Freigeben &amp; senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?', 'Vorschau ansehen', 'Einfügen', 'Text der Antwort', 'Antwort des Kunden']
const P4_MERKMALE = ['data-opens', 'aria-expanded', 'hk-feld-panel', 'hk-feld-antwort', 'hk-feld-einfuegen', 'hk-zaehler', 'hk-brief-hinweis-bau']
test('Stufe 3 (gerendert): kein Brief, keine Art und kein Status zeigt eine P4-Aktion; Stufe 6 zeigt sie weiter', async () => {
const { renderBrief } = await loadList()
const now = Date.now()
const me = { id: 'u1', name: 'Justin' }
const prospect = { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72, email: 'x@verein.example' }
const base = { $id: 'a', status: 'offen', title: 'Eintrag', draft: 'Hallo', createdAt: new Date(now).toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect, prospectId: 'p1' }
const vorschau = { ...base, kind: 'vorschau_freigabe', sourceUrl: 'https://entwurf.project.webklar.com/verein' }
const alle = [
...['offen', 'in_arbeit', 'veraltet', 'gesendet', 'erledigt', 'verworfen'].flatMap((status) => [
{ ...base, kind: 'plattform_antwort', status }, { ...base, kind: 'mail_antwort', status }, { ...vorschau, status },
{ ...base, kind: 'unsicher', status }, { ...base, kind: 'preisfrage', status }, { ...base, kind: 'mensch_gewuenscht', status },
{ ...base, kind: 'beschwerde', status }, { ...base, kind: 'bau_fehlgeschlagen', status },
]),
{ ...base, kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2' }, // von einem Kollegen übernommen
{ ...base, kind: 'plattform_antwort', status: 'gesendet', prospect: null },
]
for (const item of alle) {
const name = `${item.kind}/${item.status}${item.claimedBy ? '/Kollege' : ''}`
const html = renderBrief(item, { me, nowMs: now, employees: [], stufe: 3 })
for (const t of P4_TEXTE) assert.ok(!html.includes(t), `${name}: „${t}“`)
for (const m of P4_MERKMALE) assert.ok(!html.includes(m), `${name}: ${m}`)
assert.ok(html.includes('hk-brief'), name) // der Brief selbst steht
}
// Gegenprobe Stufe 6: dieselben Einträge zeigen die P4-Aktionen (sonst prüft die Schleife oben nichts)
const gezeigt = (item) => P4_TEXTE.filter((t) => renderBrief(item, { me, nowMs: now, employees: [], stufe: 6 }).includes(t))
assert.ok(gezeigt({ ...base, kind: 'plattform_antwort' }).includes('Antwort des Kunden einfügen'))
assert.ok(gezeigt({ ...base, kind: 'mail_antwort' }).includes('Als Mensch antworten'))
assert.ok(gezeigt(vorschau).includes('Freigeben &amp; senden'))
assert.ok(gezeigt({ ...base, kind: 'unsicher' }).includes('Vorschau bauen'))
})
test('Stufe 3 (gerendert): die P3-Aktionen je Art bleiben unverändert, Mail-Link, Anzeige öffnen und Interessent auch; abgeschlossene Einträge sagen es', async () => {
const { renderBrief } = await loadList()
const now = Date.now()
const me = { id: 'u1', name: 'Justin' }
const prospect = { $id: 'p1', name: 'Verein', email: 'x@verein.example' }
const base = { $id: 'a', status: 'offen', title: 'Eintrag', draft: 'Hallo', createdAt: new Date(now).toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect, prospectId: 'p1' }
const brief = (item) => renderBrief(item, { me, nowMs: now, employees: [], stufe: 3 })
// Plattform-Antwort: übernehmen, Kopieren & öffnen, als gesendet markieren, verwerfen
let html = brief({ ...base, kind: 'plattform_antwort' })
for (const t of ['Kopieren &amp; öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Anzeige öffnen', 'Interessent']) assert.ok(html.includes(t), t)
assert.ok(!stempel(html, 'Erledigt'))
// Mail-Antwort: E-Mail schreiben (mailto) bleibt
html = brief({ ...base, kind: 'mail_antwort', sourceUrl: '' })
assert.match(html, /<a class="hk-stempel hk-stempel-haupt" href="mailto:x@verein\.example\?[^"]*"><span class="hk-ic hk-ic-art-mail" aria-hidden="true"><\/span>E-Mail schreiben<\/a>/)
assert.ok(html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren')); assert.ok(html.includes('Verwerfen'))
// nicht sendbare Arten: Erledigt und Verwerfen; „unsicher“ bietet kein Bauen mehr
for (const kind of ['unsicher', 'preisfrage', 'mensch_gewuenscht', 'beschwerde', 'bau_fehlgeschlagen']) {
html = brief({ ...base, kind })
assert.ok(stempel(html, 'Erledigt'), kind); assert.ok(html.includes('Verwerfen'), kind); assert.ok(html.includes('Ich kümmere mich'), kind); assert.ok(!html.includes('Als gesendet markieren'), kind)
}
// Vorschau-Freigabe ohne P4: übernehmen und verwerfen (Erledigt gibt es dort nie), mit dem Hinweis des Verwerfens
html = brief({ ...base, kind: 'vorschau_freigabe', sourceUrl: 'https://entwurf.project.webklar.com/verein' })
assert.ok(html.includes('Ich kümmere mich')); assert.ok(html.includes('Verwerfen')); assert.ok(!stempel(html, 'Erledigt')); assert.ok(html.includes('„Verwerfen“ sendet die Vorschau nicht.'))
// abgeschlossen: keine Aktionsgruppe, auch nicht für „gesendet mit Interessent“ (dort stand bisher nur „Antwort des Kunden einfügen“)
html = brief({ ...base, kind: 'plattform_antwort', status: 'gesendet' })
assert.ok(!html.includes('hk-aktionen')); assert.ok(html.includes('Dieser Eintrag ist abgeschlossen.')); assert.ok(html.includes('<h3 class="hk-brief-h">Entwurf</h3>'))
})
test('Stufe 3 (bedient): das Pult arbeitet mit den P3-Aktionen, ohne P4-Knöpfe, ohne Loch in der Aktionsgruppe und ohne Fokusfalle', async () => {
const api = scriptedApi(Promise.resolve({}), Promise.resolve({}), Promise.resolve({}))
const ui = await startList([{ ...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' } }], api, { stufe: 3 })
try {
await ui.open(0)
const aktionen = nodes(ui.brief(), (n) => n.tag === 'div' && n.props.role === 'group' && hasClass(n, 'hk-aktionen'))[0]
assert.ok(aktionen)
// die Gruppe enthält genau die P3-Knöpfe, in dieser Reihenfolge, ohne leere Plätze
const beschriftung = aktionen.children.filter((c) => c !== null && c !== false).map((c) => textOf(c).trim())
assert.deepEqual(beschriftung, ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen'])
assert.equal(aktionen.children.length, aktionen.children.filter((c) => c !== null && c !== false).length)
// nichts im Brief öffnet ein Feld; kein Textfeld außer dem Entwurf
assert.equal(nodes(ui.brief(), (n) => n.props && (n.props['data-opens'] !== undefined || n.props['aria-expanded'] !== undefined)).length, 0)
assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1)
for (const label of ['Antwort des Kunden einfügen', 'Als Mensch antworten', 'Jetzt senden', 'Freigeben & senden', 'Vorschau bauen']) assert.equal(ui.has(label), false, label)
// Esc schließt den Brief (es gibt kein Feld, das zuerst schließen müsste), Umschlag bleibt bedienbar
const esc = await ui.briefKey('Escape'); assert.equal(esc.defaultPrevented, true); assert.equal(ui.brief(), null)
// P3 läuft: erster Klick auf Verwerfen fragt, der zweite verwirft; es geht nur discard hinaus
await ui.click('Verwerfen')
assert.deepEqual(ui.disabled('Wirklich verwerfen?'), [false]); assert.equal(api.calls.length, 0)
await ui.click('Wirklich verwerfen?')
assert.deepEqual(api.calls, [['k1', 'discard', {}]])
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Suche Webdesigner“ verworfen.', role: 'status' }])
} finally { ui.end() }
})
test('Stufe 3 (bedient): „unsicher“ und „Vorschau freigeben“ zeigen kein Bauen und kein Freigeben; Erledigt und Verwerfen bleiben; die Zahl der Umschläge bleibt gleich', async () => {
const api = scriptedApi()
const vorschau = { ...unsicher, $id: 'v1', kind: 'vorschau_freigabe', title: 'Vorschau Verein', sourceUrl: 'https://entwurf.project.webklar.com/verein' }
const ui = await startList([unsicher, vorschau], api, { stufe: 3 })
try {
assert.equal(envelopes(ui.tree()).length, 2)
await ui.open(0)
assert.deepEqual(['Erledigt', 'Verwerfen', 'Ich kümmere mich'].map((l) => ui.has(l)), [true, true, true])
assert.deepEqual(['Vorschau bauen', 'Als Mensch antworten', 'Antwort des Kunden einfügen'].map((l) => ui.has(l)), [false, false, false])
await ui.close()
await ui.open(1)
assert.deepEqual(['Verwerfen', 'Ich kümmere mich'].map((l) => ui.has(l)), [true, true])
assert.deepEqual(['Freigeben & senden', 'Vorschau ansehen', 'Erledigt'].map((l) => ui.has(l)), [false, false, false])
assert.equal(api.calls.length, 0)
} finally { ui.end() }
})