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
|
// die Liste enthält keine Links: ein Klick wählt nur aus
|
||||||
assert.doesNotMatch(l, /<a[\s>]|<Link[\s>]|href=/)
|
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