Workers P2: Einstellungs-Dialog für Admins
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in new issue
Block a user