Workers P2: Einstellungs-Dialog für Admins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 04:05:30 +00:00
1 parent f2975e26f0
commit e6bc2d938a
2 files changed
+271

No files matched your search

@@ -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 (
<Field label={label} htmlFor={id} hint={hint}>
<Tag
id={id}
name={name}
className={Tag}
value={form[name]}
onChange={(e) => onChange(name, e.target.value)}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-error` : undefined}
{...rest}
>
{children}
</Tag>
{error && <div className="msg err" id={`${id}-error`}>{error}</div>}
</Field>
)
}
// Gruppe mit Überschrift (nur Haarlinie, keine Karte; Stil in workers.css)
function Group({ title, children }) {
return (
<section className="wk-set-group">
<h4 className="wk-set-title">{title}</h4>
{children}
</section>
)
}
// 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 (
<Dialog
as="form"
wide
title="Hustler-Einstellungen"
busy={busy}
onClose={onClose}
onSubmit={submit}
footer={
<>
<span className="grow" />
<Button type="button" onClick={onClose} disabled={busy}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={!canSave}>{saving ? 'Speichert …' : 'Speichern'}</Button>
</>
}
>
{!ready && !loadError && <div className="loading">Wird geladen …</div>}
{!ready && loadError && <div className="msg err" role="alert">{loadError}</div>}
{ready && (
<>
{halted && (
<section className="wk-halt">
<h4 className="wk-set-title">Versand gestoppt</h4>
<p>{haltReason || 'Grund nicht angegeben'}</p>
{releaseError && <div className="msg err" role="alert">{releaseError}</div>}
<Button type="button" onClick={release} disabled={busy}>{releasing ? 'Wird freigegeben …' : 'Versand wieder freigeben'}</Button>
</section>
)}
{error && <div className="msg err" role="alert" ref={errorRef}>{error}</div>}
<Group title="Kosten">
<div className="field-row-3">
<SettingField {...fieldProps} name="dailyBudgetUsd" label="Tagesgrenze (USD)" inputMode="decimal" autoComplete="off" />
<SettingField {...fieldProps} name="monthlyBudgetUsd" label="Monatsgrenze (USD)" inputMode="decimal" autoComplete="off" />
<SettingField {...fieldProps} name="warnPct" label="Warnschwelle (%)" inputMode="numeric" autoComplete="off" />
</div>
</Group>
<Group title="Mails">
<div className="field-row">
<SettingField {...fieldProps} name="mailColdPerDay" label="Unverlangte Mails pro Tag" hint="Höchstens 3 (Spec §8.1)." inputMode="numeric" autoComplete="off" />
<SettingField {...fieldProps} name="mailReplyCapPerDay" label="Antwort-Mails pro Tag" inputMode="numeric" autoComplete="off" />
</div>
<div className="field-row">
<SettingField {...fieldProps} name="sendWindowStart" label="Sendefenster von" type="time" />
<SettingField {...fieldProps} name="sendWindowEnd" label="Sendefenster bis" type="time" />
</div>
<label className="check">
<input type="checkbox" checked={form.sendWeekdaysOnly === true} onChange={(e) => setField('sendWeekdaysOnly', e.target.checked)} /> nur werktags
</label>
</Group>
<Group title="Läufe">
<div className="field-row">
<SettingField {...fieldProps} name="runsPerNight" label="Läufe pro Nacht" inputMode="numeric" autoComplete="off" />
<SettingField {...fieldProps} name="candidatesPerRun" label="Kandidaten pro Lauf" inputMode="numeric" autoComplete="off" />
</div>
</Group>
<Group title="Räume">
<table className="table">
<tbody>
{ROOM_CHOICES.map((c) => (
<tr key={c.id}>
<td>{c.title}</td>
<td>
<label className="check">
<input
type="checkbox"
aria-label={`${c.title}: aktiv`}
checked={form.activeRooms.includes(c.id)}
onChange={(e) => toggleRoom(c.id, e.target.checked)}
/> aktiv
</label>
</td>
</tr>
))}
</tbody>
</table>
{errors.activeRooms && <div className="msg err">{errors.activeRooms}</div>}
<p className="muted">Angeschlossen (meldet der Dienst): {connectedText(settings.connectedRooms)}</p>
</Group>
<Group title="Regionen">
<SettingField {...fieldProps} name="regions" label="Regionen (eine je Zeile)" tag="textarea" rows={4} />
</Group>
<Group title="Vorschau">
<div className="field-row">
<SettingField {...fieldProps} name="previewGate" label="Freigabe vor dem Versand" hint="Welche Vorschau-Links erst über das Freigabe-Pult gehen." tag="select">
<option value="erste_n">nur die ersten N</option>
<option value="immer">immer</option>
<option value="nie">nie</option>
</SettingField>
<SettingField {...fieldProps} name="previewGateN" label="Anzahl N" inputMode="numeric" autoComplete="off" />
</div>
{typeof settings.previewsSent === 'number' && <p className="muted">Bisher verschickt: {settings.previewsSent}</p>}
</Group>
<Group title="Angebot">
<SettingField {...fieldProps} name="offerText" label="Angebotstext" tag="textarea" rows={4} hint="1 bis 400 Zeichen." />
{typeof settings.offerProductKey === 'string' && settings.offerProductKey && <p className="muted">Produkt: {settings.offerProductKey}</p>}
</Group>
<Group title="Fristen">
<div className="field-row">
<SettingField {...fieldProps} name="pultStaleDays" label="Pult veraltet nach (Tage)" inputMode="numeric" autoComplete="off" />
<SettingField {...fieldProps} name="retentionDays" label="Aufbewahrung (Tage)" inputMode="numeric" autoComplete="off" />
</div>
<div className="field-row">
<SettingField {...fieldProps} name="followupDays" label="Nachfassen nach (Tage)" inputMode="numeric" autoComplete="off" />
<SettingField {...fieldProps} name="previewReminderDays" label="Erinnerung nach (Tage)" inputMode="numeric" autoComplete="off" />
</div>
<div className="field-row">
<SettingField {...fieldProps} name="previewDeleteDays" label="Vorschau löschen nach (Tage)" inputMode="numeric" autoComplete="off" />
<SettingField {...fieldProps} name="reportEmail" label="Bericht an" inputMode="email" autoComplete="off" />
</div>
</Group>
</>
)}
</Dialog>
)
}
+11
View File
@@ -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, /<a[\s>]|<Link[\s>]|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/)
})