Workers P4: Pult – als Mensch antworten, Vorschau freigeben, Antwort des Kunden einfügen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be2e6786b6
commit
533091c333
7 files changed
+1001
-48
No files matched your search
@@ -5,11 +5,13 @@ import EmptyState from '../ui/EmptyState'
|
||||
import { copyTextChecked } from '../CopyableCredential'
|
||||
import { 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
@@ -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
@@ -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.'
|
||||
}
|
||||
@@ -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 ---------- */
|
||||
|
||||
Reference in new issue
Block a user