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:
1 parent
7cd74a23d2
commit
5e48fbf264
5 files changed
+207
-15
No files matched your search
@@ -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-
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 { useEmployees } from '../../hooks/useEmployees'
|
||||||
import { usePultFeed } from '../../hooks/usePultFeed'
|
import { usePultFeed } from '../../hooks/usePultFeed'
|
||||||
import { reiterZahl, wartetZahl, botenLaufen } from '../../lib/hustlerPult'
|
import { reiterZahl, wartetZahl, botenLaufen } from '../../lib/hustlerPult'
|
||||||
|
import { HUSTLER_STUFE } from '../../lib/hustlerStufe'
|
||||||
import { activeControl, restoreFocus } from '../../lib/workersFocus'
|
import { activeControl, restoreFocus } from '../../lib/workersFocus'
|
||||||
import { PULT_FILTERS, rowModel } from '../../lib/workersPult'
|
import { PULT_FILTERS, rowModel } from '../../lib/workersPult'
|
||||||
import Brief from './pult/Brief'
|
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
|
// 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)
|
||||||
|
|
||||||
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 [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
|
||||||
@@ -123,7 +125,7 @@ export default function PultList({ api, me, kpis, paused = false, onBack }) {
|
|||||||
return true
|
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
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// Plattform-Antworten schickt der Mensch selbst ab (Plattform) und markiert sie danach als gesendet; Mail-Antworten und die Freigabe
|
// 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.
|
// 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 { DOWN_TEXT, SLOW_PULT_ACTIONS, UNCERTAIN_STATUS } from './workersApi'
|
||||||
import { roleOf } from './workersRoster'
|
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.
|
* 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
|
* 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).
|
* 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 editable = BEARBEITBAR.includes(item.status)
|
||||||
const sendable = SENDABLE_KINDS.includes(item.kind)
|
const sendable = SENDABLE_KINDS.includes(item.kind)
|
||||||
const prospect = item.prospect ?? null
|
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)
|
// 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 free = editable && !claimedByOther
|
||||||
const freigabe = item.kind === 'vorschau_freigabe'
|
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 : ''
|
const draft = typeof item.draft === 'string' ? item.draft : ''
|
||||||
let mail = ''
|
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)
|
// Erledigt würde eine Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: dort bleibt nur Freigeben oder Verwerfen (D20)
|
||||||
canDone: !sendable && editable && !freigabe,
|
canDone: !sendable && editable && !freigabe,
|
||||||
// Mail-Antwort als Mensch: braucht einen Interessenten mit Adresse („Mail ohne Faden“ und Plattform-Interessenten haben keinen Mail-Weg)
|
// 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),
|
canReply: p4 && REPLY_KINDS.includes(item.kind) && free && prospect !== null && filled(prospect.email),
|
||||||
canApprovePreview: freigabe && free,
|
canApprovePreview: p4 && freigabe && free,
|
||||||
// Bau starten wie ein „Ja“: Eintrag „unsicher“ mit Interessent. Ein toter Interessent (gesperrt, verloren, Deal) bekommt den Knopf nicht; ohne
|
// 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)
|
// 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,
|
// 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.
|
// 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,
|
claimedByMe,
|
||||||
claimedByOther,
|
claimedByOther,
|
||||||
claimedByLabel,
|
claimedByLabel,
|
||||||
|
|||||||
@@ -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 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 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 })
|
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.__pultEmployees = EMPLOYEES
|
||||||
globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra })
|
globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra })
|
||||||
delete globalThis.__panel
|
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 = {
|
const view = {
|
||||||
timers, refreshes,
|
timers, refreshes,
|
||||||
tree: () => mounted.tree(),
|
tree: () => mounted.tree(),
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { loadModule } from './load.mjs'
|
|||||||
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
|
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
|
||||||
const {
|
const {
|
||||||
PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, REPLY_KINDS, PASTE_KINDS, REPLY_TEXT_MAX, UNCERTAIN_TEXT,
|
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')
|
} = 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 NOW = Date.parse('2026-10-19T09:00:00Z')
|
||||||
const MIN = 60 * 1000
|
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.')
|
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))
|
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)
|
||||||
|
})
|
||||||
@@ -248,9 +248,11 @@ async function loadList() {
|
|||||||
"import PultList from './src/components/workers/PultList.jsx'",
|
"import PultList from './src/components/workers/PultList.jsx'",
|
||||||
"import Brief from './src/components/workers/pult/Brief.jsx'",
|
"import Brief from './src/components/workers/pult/Brief.jsx'",
|
||||||
"import { rowModel } from './src/lib/workersPult.js'",
|
"import { rowModel } from './src/lib/workersPult.js'",
|
||||||
'export const render = (props) => renderToStaticMarkup(createElement(PultList, props))',
|
// Stufen-Sperre (hustlerStufe.js): die Zusicherungen dieser Datei gelten für das volle Pult (Stufe 6, P4-Aktionen offen, Verhalten wie vor der Sperre);
|
||||||
'export const renderBrief = (item, { me, nowMs, employees, text, fields } = {}) => {',
|
// die Tests „Stufe 3“ am Ende der Datei reichen stufe: 3 durch
|
||||||
' const row = rowModel(item, { me, nowMs, employees })',
|
'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() {} }))',
|
' return renderToStaticMarkup(createElement(Brief, { api: {}, item, row, text: text ?? row.copyText, fields: fields ?? {}, onText() {}, onField() {}, refresh: async () => {}, onFinished() {}, onClose() {}, onDetails() {}, onSpaet() {} }))',
|
||||||
'}',
|
'}',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
@@ -571,7 +573,7 @@ async function startList(items, api, props = {}) {
|
|||||||
}
|
}
|
||||||
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' }, ...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
|
let current = null // Nummer des Umschlags, dessen Brief offen ist
|
||||||
const view = {
|
const view = {
|
||||||
refreshes, timers, opened,
|
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' }])
|
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
|
||||||
} finally { ui.end() }
|
} 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 & 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 & 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 & ö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() }
|
||||||
|
})
|
||||||
Reference in new issue
Block a user