diff --git a/src/components/workers/WorkersSettingsDialog.jsx b/src/components/workers/WorkersSettingsDialog.jsx new file mode 100644 index 0000000..ba24542 --- /dev/null +++ b/src/components/workers/WorkersSettingsDialog.jsx @@ -0,0 +1,260 @@ +import { useEffect, useRef, useState } from 'react' +import { useScrollIntoView } from '../../hooks/useScrollIntoView' +import Button from '../ui/Button' +import Dialog from '../ui/Dialog' +import Field from '../ui/Field' +import { BAD_REPLY_TEXT } from '../../lib/workersApi' +import { ROOM_TITLES } from '../../lib/workersRoster' +import { ROOM_CHOICES, formToPatch, settingsToForm, validateForm } from '../../lib/workersSettingsForm' + +const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +// Ein Feld mit Beschriftung. Bei einem Fehler trägt das Eingabeelement aria-invalid und verweist auf den Text darunter. +// `tag` ist 'input', 'textarea' oder 'select'; zugleich der Klassenname der globalen Eingabe-Stile. +function SettingField({ name, label, hint, tag: Tag = 'input', form, errors, onChange, children, ...rest }) { + const id = `wk-set-${name}` + const error = errors[name] + return ( + + onChange(name, e.target.value)} + aria-invalid={error ? 'true' : undefined} + aria-describedby={error ? `${id}-error` : undefined} + {...rest} + > + {children} + + {error &&
{error}
} +
+ ) +} + +// Gruppe mit Überschrift (nur Haarlinie, keine Karte; Stil in workers.css) +function Group({ title, children }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +// Räume, die der Dienst als angeschlossen meldet, als Text (Ids ohne Titel bleiben als Id stehen) +function connectedText(rooms) { + if (!Array.isArray(rooms) || rooms.length === 0) return 'keiner' + return rooms.map((id) => ROOM_TITLES[id] ?? String(id)).join(', ') +} + +// Einstellungen der Hustler-Seite (nur für Admins). Lädt beim Öffnen, prüft live mit den Grenzen des Dienstes und +// sendet nur den Patch mit den geänderten Feldern. Die Notbremse (Versand gestoppt) steht außerhalb des Formulars: +// ihr Knopf sendet genau ein Feld und berührt weder Eingaben noch Patch. +export default function WorkersSettingsDialog({ api, onClose, onSaved }) { + const [settings, setSettings] = useState(null) + const [form, setForm] = useState(null) + const [loadError, setLoadError] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [releasing, setReleasing] = useState(false) + const [releaseError, setReleaseError] = useState('') + const errorRef = useRef(null) + useScrollIntoView(errorRef, error) + + // Einmal beim Öffnen laden; ein Schließen währenddessen verwirft die Antwort + useEffect(() => { + let alive = true + const load = async () => { + try { + const loaded = await api.settings() + if (!alive) return + if (!isRecord(loaded)) { + setLoadError(BAD_REPLY_TEXT) + return + } + setSettings(loaded) + setForm(settingsToForm(loaded)) + } catch (err) { + if (alive) setLoadError(err?.message || BAD_REPLY_TEXT) + } + } + load() + return () => { + alive = false + } + }, []) + + const ready = settings !== null && form !== null + const errors = ready ? validateForm(form).errors : {} + const patch = ready ? formToPatch(form, settings) : {} + const busy = saving || releasing + const canSave = ready && !busy && Object.keys(errors).length === 0 && Object.keys(patch).length > 0 + + const setField = (name, value) => setForm((f) => ({ ...f, [name]: value })) + const toggleRoom = (id, on) => + setForm((f) => ({ ...f, activeRooms: on ? [...f.activeRooms.filter((r) => r !== id), id] : f.activeRooms.filter((r) => r !== id) })) + + const submit = async (e) => { + e.preventDefault() + if (!canSave) return + setSaving(true) + setError('') + let saved + try { + saved = await api.saveSettings(patch) + } catch (err) { + setError(err?.message || 'Speichern fehlgeschlagen.') + setSaving(false) + return + } + if (onSaved) onSaved(saved) + onClose() + } + + // Notbremse aufheben: genau ein Feld; die Antwort kommt in `settings`, die Eingaben im Formular bleiben stehen + const release = async () => { + if (busy) return + setReleasing(true) + setReleaseError('') + let answer + try { + answer = await api.saveSettings({ sendingHalted: false }) + } catch (err) { + setReleaseError(err?.message || 'Freigeben fehlgeschlagen.') + setReleasing(false) + return + } + setSettings((prev) => ({ ...prev, ...(isRecord(answer) ? answer : {}) })) + setReleasing(false) + if (onSaved) onSaved(answer) + } + + const halted = ready && settings.sendingHalted === true + const haltReason = typeof settings?.sendingHaltedReason === 'string' ? settings.sendingHaltedReason.trim() : '' + const fieldProps = { form, errors, onChange: setField } + + return ( + + + + + + } + > + {!ready && !loadError &&
Wird geladen …
} + {!ready && loadError &&
{loadError}
} + {ready && ( + <> + {halted && ( +
+

Versand gestoppt

+

{haltReason || 'Grund nicht angegeben'}

+ {releaseError &&
{releaseError}
} + +
+ )} + {error &&
{error}
} + + +
+ + + +
+
+ + +
+ + +
+
+ + +
+ +
+ + +
+ + +
+
+ + + + + {ROOM_CHOICES.map((c) => ( + + + + + ))} + +
{c.title} + +
+ {errors.activeRooms &&
{errors.activeRooms}
} +

Angeschlossen (meldet der Dienst): {connectedText(settings.connectedRooms)}

+
+ + + + + + +
+ + + + + + +
+ {typeof settings.previewsSent === 'number' &&

Bisher verschickt: {settings.previewsSent}

} +
+ + + + {typeof settings.offerProductKey === 'string' && settings.offerProductKey &&

Produkt: {settings.offerProductKey}

} +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + )} +
+ ) +} diff --git a/tests/unit/workersComponents.test.mjs b/tests/unit/workersComponents.test.mjs index 4651c50..9585ea3 100644 --- a/tests/unit/workersComponents.test.mjs +++ b/tests/unit/workersComponents.test.mjs @@ -24,3 +24,14 @@ test('WorkersKpis nutzt Stat und kpiTiles; WorkerList: Knopf je Worker mit data- // die Liste enthält keine Links: ein Klick wählt nur aus assert.doesNotMatch(l, /]|]|href=/) }) +test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => { + const s = read('WorkersSettingsDialog.jsx') + assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/) + assert.doesNotMatch(s, /paused/) + for (const t of ['Hustler-Einstellungen', 'Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t) + assert.match(s, /disabled=\{/) + // H4/A21: Block „Versand gestoppt“ mit Freigabe, außerhalb des Formular-Patches + assert.match(s, /sendingHalted/); assert.ok(s.includes('Versand gestoppt')); assert.ok(s.includes('Versand wieder freigeben')) + assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/) + assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/) +})