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

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

No files matched your search

+151 -21
View File
@@ -5,11 +5,13 @@ import EmptyState from '../ui/EmptyState'
import { copyTextChecked } from '../CopyableCredential'
import { useEmployees } from '../../hooks/useEmployees'
import { usePultFeed } from '../../hooks/usePultFeed'
import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult'
import {
ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText,
} from '../../lib/workersPult'
import ProspectPanel from './ProspectPanel'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick
const ACTION_FAILED = 'Das hat nicht geklappt.'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ bzw. „Wirklich freigeben?“ nach dem ersten Klick
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
// Leerzustand je Filter
const EMPTY = {
@@ -42,12 +44,29 @@ function rowName(label, title, joiner = ': ') {
return t === '' ? label : `${label}${joiner}${t}`
}
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (.wk-pult-reply) und „Antwort des Kunden einfügen“ (.wk-pult-paste).
// Textfeld, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und ein Knopf; die Prüfung macht die Zeile beim Absenden.
function TextPanel({ className, label, hint, value, onChange, submitLabel, onSubmit, busy }) {
const count = charCount(value)
return (
<div className={className}>
{hint && <p className="wk-hint">{hint}</p>}
<textarea className="textarea" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} autoFocus />
<div className="wk-pult-panel-foot">
<span className={count > REPLY_TEXT_MAX ? 'wk-pult-count over' : 'wk-pult-count'}>{count} / {REPLY_TEXT_MAX}</span>
<Button type="button" variant="primary" onClick={onSubmit} disabled={busy}>{submitLabel}</Button>
</div>
</div>
)
}
// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der
// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und
// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails }) {
function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFinished, onDetails }) {
const [busy, setBusy] = useState(false)
const [confirming, setConfirming] = useState(false)
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve': welche Rückfrage gerade gilt
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
const aliveRef = useRef(true)
@@ -57,10 +76,10 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
return () => { aliveRef.current = false }
}, [])
// Die Rückfrage vor dem Verwerfen verfällt nach 4 s
// Die Rückfrage vor dem Verwerfen und vor dem Freigeben verfällt nach 4 s
useEffect(() => {
if (!confirming) return undefined
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS)
if (confirming === null) return undefined
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
return () => window.clearTimeout(timer)
}, [confirming])
@@ -68,7 +87,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
if (busyRef.current) return false
busyRef.current = true
setBusy(true)
setConfirming(false)
setConfirming(null)
setNote(null)
return true
}
@@ -78,17 +97,23 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
}
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile.
const run = async (call, done, finished) => {
if (!begin()) return
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
// approve-preview und start-build heißt eine Zeitüberschreitung „Kann gesendet sein – Pult neu laden.“, D14). Ergebnis: ob es geklappt hat.
const run = async (call, done, finished, action) => {
if (!begin()) return false
let ok = false
try {
await call()
if (finished) onFinished(row.id, `„${row.title}“ ${done}`)
else if (aliveRef.current) setNote({ kind: 'ok', text: done })
const result = await call()
const message = typeof done === 'function' ? done(result) : done
if (finished) onFinished(row.id, `„${row.title}“ ${message}`)
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
ok = true
} catch (err) {
if (aliveRef.current) setNote({ kind: 'err', text: err?.message || ACTION_FAILED })
if (aliveRef.current) setNote({ kind: 'err', text: actionErrorText(err, action) })
}
await refresh()
end()
return ok
}
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
@@ -113,13 +138,57 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
}
const discard = () => {
if (!confirming) {
setConfirming(true)
if (confirming !== 'discard') {
setConfirming('discard')
return
}
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
}
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster)
const approve = () => {
if (confirming !== 'approve') {
setConfirming('approve')
return
}
run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
}
// Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“)
const startBuild = () => run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
// Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
// vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text einen Wechsel des Filters überlebt.
const replyValue = fields.reply ?? text
const pasteValue = fields.paste ?? ''
const toggle = (which) => setOpen((current) => (current === which ? null : which))
const sendReply = () => {
const check = validateReplyText(replyValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
}
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
const sendPaste = () => {
const check = validateReplyText(pasteValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(async () => {
const result = await api.pultAction(row.id, 'paste', { text: check.text })
onField(row.id, 'paste', '')
if (aliveRef.current) setOpen(null)
return result
}, pasteDoneText, false, 'paste')
}
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
const { prospect } = row
const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
@@ -173,7 +242,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
)
)}
{(row.canSend || row.canClaim || row.canDone || row.canDiscard) && (
{hasActions && (
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)}>
{row.canSend && row.kind === 'mail_antwort' && (
mail
@@ -195,19 +264,72 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
Als gesendet markieren
</Button>
)}
{row.canReply && (
<Button type="button" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} disabled={busy}>
Als Mensch antworten
</Button>
)}
{row.canApprovePreview && (
<>
{row.previewHref
? <a className="btn" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}>Vorschau ansehen</a>
: <span className="wk-hint">Keine gültige Vorschau-Adresse hinterlegt.</span>}
<Button type="button" variant="primary" onClick={approve} disabled={busy}>
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
</Button>
</>
)}
{row.canStartBuild && (
<Button type="button" onClick={startBuild} disabled={busy}>
Vorschau bauen
</Button>
)}
{row.canPaste && (
<Button type="button" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} disabled={busy}>
Antwort des Kunden einfügen
</Button>
)}
{row.canDone && (
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
Erledigt
</Button>
)}
{row.canDiscard && (
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
{confirming ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Button>
<>
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Button>
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
{row.kind === 'vorschau_freigabe' && <span className="wk-hint">„Verwerfen“ sendet die Vorschau nicht.</span>}
</>
)}
</div>
)}
{open === 'reply' && row.canReply && (
<TextPanel
className="wk-pult-reply"
label={rowName('Text der Antwort', row.title)}
value={replyValue}
onChange={(value) => onField(row.id, 'reply', value)}
submitLabel="Jetzt senden"
onSubmit={sendReply}
busy={busy}
/>
)}
{open === 'paste' && row.canPaste && (
<TextPanel
className="wk-pult-paste"
label={rowName('Antwort des Kunden', row.title)}
hint="Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf."
value={pasteValue}
onChange={(value) => onField(row.id, 'paste', value)}
submitLabel="Einfügen"
onSubmit={sendPaste}
busy={busy}
/>
)}
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
</li>
)
@@ -219,6 +341,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
export default function PultList({ api, me }) {
const [filterId, setFilterId] = useState('offen')
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
@@ -231,12 +354,17 @@ export default function PultList({ api, me }) {
setFinished('')
}
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
const onFinished = useCallback((id, message) => {
setFinished(message)
setDrafts((d) => {
const { [id]: _gone, ...rest } = d
return rest
})
setFields((f) => {
const { [id]: _gone, ...rest } = f
return rest
})
}, [])
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
@@ -265,7 +393,9 @@ export default function PultList({ api, me }) {
item={item}
row={row}
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
fields={Object.hasOwn(fields, row.id) ? fields[row.id] : NO_FIELDS}
onText={setText}
onField={setField}
refresh={refresh}
onFinished={onFinished}
onDetails={setDetailId}
+17 -10
View File
@@ -11,6 +11,9 @@ export const INVALID_ID_TEXT = 'Ungültige Kennung.'
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
export const REQUEST_TIMEOUT_MS = 20000
// Zeitgrenze für Pult-Aktionen, die im Dienst länger dauern (Mail senden, Freigabe einreihen, Bau anlegen): nach 20 s als „nicht erreichbar“
// abzubrechen, obwohl die Mail gerade hinausgeht, ließe den Menschen ein zweites Mal klicken. Alle anderen Abrufe behalten die kurze Frist.
export const ACTION_TIMEOUT_MS = 60000
// Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder Appwrite lehnt createJWT ab) wird so lange kein frisches JWT mehr
// erzwungen, und lehnt Appwrite createJWT ab (HTTP-Code ab 400, z. B. 429; kein Übertragungsfehler), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt
// createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen und damit auch Serverpuls,
@@ -20,7 +23,9 @@ export const AUTH_COOLDOWN_MS = 120000
// Pult-Status des Dienstes (Übersicht §4.11)
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
const PULT_ACTIONS = Object.freeze(['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste'])
const PULT_ACTIONS = Object.freeze(['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste', 'start-build'])
// Die Aktionen mit der langen Frist (ACTION_TIMEOUT_MS); bei einer Zeitüberschreitung ist offen, ob der Dienst sie ausgeführt hat
export const SLOW_PULT_ACTIONS = Object.freeze(['reply', 'approve-preview', 'start-build'])
const BASE = '/api/workers'
const EVENTS_LIMIT = 200
@@ -67,22 +72,23 @@ export function createWorkersApi({
getJwt,
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = REQUEST_TIMEOUT_MS,
actionTimeoutMs = ACTION_TIMEOUT_MS,
authCooldownMs = AUTH_COOLDOWN_MS,
now = Date.now,
}) {
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Folge kein frisches JWT erzeugt und nach einem gescheiterten createJWT gar keins (AUTH_COOLDOWN_MS)
let mintFailed = false // Appwrite hat createJWT abgelehnt (HTTP-Code ab 400, kein Übertragungsfehler): bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache
// Führt fn(signal) mit Zeitgrenze aus: nach timeoutMs scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
// Führt fn(signal) mit Zeitgrenze aus: nach limitMs (Standard timeoutMs) scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
// abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung).
function withDeadline(fn) {
function withDeadline(fn, limitMs = timeoutMs) {
const controller = new AbortController()
let timer
const expired = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(statusError(DOWN_TEXT, 0)) // zuerst ablehnen, dann abbrechen: das Abbruch-Ergebnis von fn soll nicht gewinnen
controller.abort()
}, timeoutMs)
}, limitMs)
})
return Promise.race([fn(controller.signal), expired]).finally(() => clearTimeout(timer))
}
@@ -142,23 +148,24 @@ export function createWorkersApi({
}
// Ein Versuch mit Zeitgrenze: JWT holen, senden, auswerten. { unauthorized: true } bei 401, sonst { data }.
function attempt(path, method, body, options) {
function attempt(path, method, body, options, limitMs) {
return withDeadline(async (signal) => {
const res = await send(path, method, body, await jwtFor(options), signal)
if (res.status === 401) return { unauthorized: true }
freshBlockedUntil = 0 // der Dienst hat das JWT angenommen: die nächste 401-Folge darf wieder einmal erneuern
return { data: await read(res) }
})
}, limitMs)
}
async function request(path, { method = 'GET', body } = {}) {
let out = await attempt(path, method, body)
// limitMs: Zeitgrenze je Versuch (Standard timeoutMs); auch der Wiederholungsversuch nach einer 401 bekommt sie
async function request(path, { method = 'GET', body, limitMs } = {}) {
let out = await attempt(path, method, body, undefined, limitMs)
if (out.unauthorized) {
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
// sonst einmal mit frischem JWT wiederholen (lehnt Appwrite createJWT ab, beginnt die Sperre in jwtFor), danach gilt die
// Sitzung als abgelaufen und die Sperre beginnt
out = await attempt(path, method, body, { fresh: true })
out = await attempt(path, method, body, { fresh: true }, limitMs)
if (out.unauthorized) {
freshBlockedUntil = now() + authCooldownMs
throw statusError(SESSION_TEXT, 401)
@@ -178,7 +185,7 @@ export function createWorkersApi({
pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`),
pultAction: async (id, action, body) => {
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body })
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body, limitMs: SLOW_PULT_ACTIONS.includes(action) ? actionTimeoutMs : undefined })
},
botleads: ({ room, q, page, id } = {}) => request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`),
prospect: async (id) => request(`/prospect/${pathId(id)}`),
+84 -7
View File
@@ -1,7 +1,10 @@
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen.
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen,
// Prüfung der Texte und Meldungen für Antworten als Mensch, Vorschau-Freigabe und eingefügte Kundenantworten (P4).
// Kein React, kein Appwrite, kein window/document beim Import: unter Node testbar (loadModule). Nichts hier versendet etwas:
// der Mensch schickt die Antwort selbst ab (Plattform oder eigenes Mailprogramm) und markiert sie danach als gesendet.
// Plattform-Antworten schickt der Mensch selbst ab (Plattform) und markiert sie danach als gesendet; Mail-Antworten und die Freigabe
// einer Vorschau gehen über den Dienst (api.pultAction), der sie durch das Sende-Tor führt.
import { SLOW_PULT_ACTIONS } from './workersApi'
import { roleOf } from './workersRoster'
const MINUTE_MS = 60 * 1000
@@ -23,6 +26,16 @@ export const PULT_KIND_LABELS = Object.freeze({
// Nur diese Arten sind Antworten, die ein Mensch absendet; alle anderen schließt er mit „Erledigt“ ab
export const SENDABLE_KINDS = Object.freeze(['plattform_antwort', 'mail_antwort'])
// P4: Arten, auf die ein Mensch aus dem Pult heraus per Mail antwortet (Spiegel von REPLY_KINDS in src/gespraeche/pultMail.js des Dienstes)
export const REPLY_KINDS = Object.freeze(['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'])
// P4: Arten, zu denen ein Mensch die Antwort des Kunden von der Plattform einfügt (Dienst: POST /pult/:id/paste)
export const PASTE_KINDS = Object.freeze(['plattform_antwort'])
// Längster Text einer Antwort oder einer eingefügten Kundenantwort (Grenze des Dienstes, gezählt in Zeichen/Code Points)
export const REPLY_TEXT_MAX = 8000
export const ACTION_FAILED = 'Das hat nicht geklappt.'
// Bei reply, approve-preview und start-build ist nach einer Zeitüberschreitung offen, ob der Dienst die Aktion ausgeführt hat (D14)
export const UNCERTAIN_TEXT = 'Kann gesendet sein – Pult neu laden.'
// Die drei Reiter der Liste; `statuses` geht unverändert als Komma-Liste an GET /pult
export const PULT_FILTERS = Object.freeze([
Object.freeze({ id: 'offen', label: 'Wartet', statuses: 'offen,in_arbeit' }),
@@ -42,6 +55,8 @@ const DONE_VIEW = Object.freeze({
erledigt: Object.freeze({ label: 'Erledigt', tone: 'muted' }),
})
const PREVIEW_HOST = 'entwurf.project.webklar.com' // Vorschauen liegen nur hier (Vorschau-Server des Dienstes)
const MAIL_SUBJECT_MAX = 78
const MAIL_BODY_MAX = 1800
@@ -92,6 +107,16 @@ export function safeExternalUrl(url) {
return parsed.href
}
/** Die Adresse einer Vorschau: nur https://entwurf.project.webklar.com/… (Standard-Port, ohne Zugangsdaten), sonst null. */
function previewUrl(url) {
const href = safeExternalUrl(url)
if (href === null) return null
const { hostname, port } = new URL(href)
return hostname === PREVIEW_HOST && port === '' ? href : null
}
const filled = (value) => typeof value === 'string' && value.trim() !== ''
// Genau eine Adresse: ein '@', links und rechts etwas, kein Leerraum (CR/LF eingeschlossen) und keine Zeichen, die in der
// mailto-Adresse einen zweiten Empfänger, weitere Kopfzeilen oder ein anderes Schema einschleusen würden (? & % , ; : # / \ < > ").
// Ein führendes „mailto:“ fällt über den Doppelpunkt heraus.
@@ -126,6 +151,14 @@ export function employeeName(by, employees) {
return listedName(by, employees) ?? KOLLEGE
}
// Anzeigename einer Worker-Kennung wie `max` oder `clara` („Max (automatisch)“); ein Mensch (`mensch:<userId>`) und jede unbekannte Kennung
// ergeben null. Ein Worker darf nie als „Kollege“ erscheinen, deshalb geht eine solche Kennung nie durch listedName.
function workerLabel(by) {
if (typeof by !== 'string' || by.startsWith(MENSCH)) return null
const role = roleOf(by)
return role ? `${role.name} (automatisch)` : null
}
/**
* Wer einen Eintrag abgeschlossen hat, als Satzteil: `mensch:<userId>` über die Mitarbeiterliste („von Anna“, unbekannt „von Kollege“),
* eine Kennung wie `max` (Worker, der automatisch gesendet hat, P4) mit dem Namen aus dem Rollen-Register („von Max (automatisch)“),
@@ -135,8 +168,8 @@ export function employeeName(by, employees) {
function doneByPhrase(by, employees) {
if (typeof by !== 'string' || by === '') return ''
if (by.startsWith(MENSCH)) return `von ${employeeName(by, employees)}`
const role = roleOf(by)
return role ? `von ${role.name} (automatisch)` : 'automatisch'
const worker = workerLabel(by)
return worker ? `von ${worker}` : 'automatisch'
}
/**
@@ -156,8 +189,11 @@ export function rowModel(item, { me, nowMs, employees }) {
let claimedByLabel = ''
if (held) {
const ownName = claimedByMe && typeof me.name === 'string' && me.name !== '' ? me.name : null // eigener Name, falls die Liste ihn nicht kennt
claimedByLabel = listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE
claimedByLabel = workerLabel(item.claimedBy) ?? listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE // Max übernimmt beim Senden selbst (P4)
}
// P4: Antworten, Freigeben und Bauen nur an einem bearbeitbaren Eintrag, den kein anderer hält (Einfügen kennt diese Sperre nicht, siehe canPaste)
const free = editable && !claimedByOther
const freigabe = item.kind === 'vorschau_freigabe'
const draft = typeof item.draft === 'string' ? item.draft : ''
let mail = ''
@@ -186,12 +222,23 @@ export function rowModel(item, { me, nowMs, employees }) {
canClaim: item.status === 'offen' || item.status === 'veraltet',
canSend: sendable && editable,
canDiscard: editable,
canDone: !sendable && editable,
// Erledigt würde eine Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: dort bleibt nur Freigeben oder Verwerfen (D20)
canDone: !sendable && editable && !freigabe,
// Mail-Antwort als Mensch: braucht einen Interessenten mit Adresse („Mail ohne Faden“ und Plattform-Interessenten haben keinen Mail-Weg)
canReply: REPLY_KINDS.includes(item.kind) && free && prospect !== null && filled(prospect.email),
canApprovePreview: freigabe && free,
// Bau starten wie ein „Ja“: Eintrag „unsicher“ mit Interessent; ob der noch lebt, entscheidet der Dienst (409 mit deutschem Satz)
canStartBuild: item.kind === 'unsicher' && free && filled(item.prospectId),
// Die Rückmeldung des Kunden kommt meist erst, nachdem jemand Claras Entwurf auf der Plattform gesendet hat: auch bei gesendet,
// erledigt oder von einem Kollegen übernommen. Nur ein verworfener Eintrag nimmt nichts mehr an.
canPaste: PASTE_KINDS.includes(item.kind) && item.status !== 'verworfen' && prospect !== null,
claimedByMe,
claimedByOther,
claimedByLabel,
copyText: draft,
sourceHref: safeExternalUrl(item.sourceUrl),
// Bei vorschau_freigabe ist sourceUrl die Vorschau, keine Anzeige: die Zeile bietet dort previewHref statt „Anzeige öffnen“
sourceHref: freigabe ? null : safeExternalUrl(item.sourceUrl),
previewHref: freigabe ? previewUrl(item.sourceUrl) : null,
mailtoHref: mail,
prospect,
}
@@ -223,3 +270,33 @@ export async function copyAndOpen({ text, url, copy, open }) {
}
return { copied: await copied, opened }
}
/** Zeichen eines Textes wie der Dienst sie zählt: getrimmt, in Code Points (ein Emoji ist ein Zeichen). */
export function charCount(text) {
return typeof text === 'string' ? Array.from(text.trim()).length : 0
}
/**
* Prüft den Text von „Als Mensch antworten“ und „Antwort des Kunden einfügen“ vor dem Senden (beide Grenzen sind die des Dienstes).
* Ergebnis { ok: true, text } mit getrimmtem Text oder { ok: false, error } mit deutschem Satz.
*/
export function validateReplyText(text) {
const clean = typeof text === 'string' ? text.trim() : ''
if (clean === '') return { ok: false, error: 'Bitte einen Text eingeben.' }
if (charCount(clean) > REPLY_TEXT_MAX) return { ok: false, error: `Der Text ist zu lang (höchstens ${REPLY_TEXT_MAX} Zeichen).` }
return { ok: true, text: clean }
}
/**
* Meldung der Zeile bei einer gescheiterten Pult-Aktion: der Satz des Dienstes. Eine Zeitüberschreitung oder ein Netzfehler (status 0) bei
* reply, approve-preview und start-build heißt nicht „nicht gesendet“: der Dienst kann die Aktion noch ausgeführt haben (D14).
*/
export function actionErrorText(err, action) {
if (SLOW_PULT_ACTIONS.includes(action) && err?.status === 0) return UNCERTAIN_TEXT
return typeof err?.message === 'string' && err.message !== '' ? err.message : ACTION_FAILED
}
/** Meldung nach „Antwort des Kunden einfügen“: der Dienst antwortet mit status neu | fortgesetzt | doppelt. */
export function pasteDoneText(result) {
return result?.status === 'doppelt' ? 'Diese Antwort war schon eingefügt.' : 'Eingefügt. Nora sortiert die Antwort ein.'
}
+19
View File
@@ -199,6 +199,22 @@
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
/* Aufgeklappte Felder unter den Aktionen: „Als Mensch antworten“ (reply) und „Antwort des Kunden einfügen“ (paste). Haarlinie darüber, volle Breite. */
.wk-pult-row .wk-pult-reply, .wk-pult-row .wk-pult-paste {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.wk-pult-reply .wk-hint, .wk-pult-paste .wk-hint { margin: 0; }
.wk-pult-reply .textarea, .wk-pult-paste .textarea { display: block; width: 100%; min-height: 140px; }
.wk-pult-panel-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
.wk-pult-count { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; }
.wk-pult-count.over { color: var(--red-fg); }
/* ---------- Interessenten-Panel (Dialog) ---------- */
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
@@ -236,6 +252,9 @@
.wk-pult-row { padding: 12px 0; }
.wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
.wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
/* Felder: Zähler und Knopf untereinander, der Knopf über die volle Breite */
.wk-pult-panel-foot { flex-direction: column; align-items: stretch; }
.wk-pult-panel-foot .btn { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
}
/* ---------- Weniger Bewegung ---------- */