Öffnungszeiten: Team-Panel, Ersatz-HTML und Rechtliches-Karte

Panel unter der Zeile Öffnungszeiten live (Header-Selektor, Kontakt-/Hinweis-Leiste aus, Speichern mit teamVersion,
Ersatz-HTML kopieren je Plan), Meldung zum Ersatz-Ticket nach dem Abschalten, Hindernis an aktionen, Statustext der Zeiten,
Team-API (GET ersatz, PATCH team), Karte Leistungsbeschreibung Öffnungszeiten live in setup-legal (Schlüssel höchstens 36 Zeichen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:12:04 +00:00
1 parent 994347408d
commit 3d67aa88cf
10 files changed
+498 -6

No files matched your search

+13 -3
View File
@@ -10,6 +10,8 @@ import {
} from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd } from '../../lib/format'
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
import ZeitenPanel from './ZeitenPanel'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
@@ -186,7 +188,7 @@ export default function CustomerFunktionen({ customerId }) {
setStatus(null)
try {
const mitTextform = textformNoetig(key, action, termine)
const { projects: changed } = await setFeature(customerId, key, {
const { projects: changed, ersatzTicket } = await setFeature(customerId, key, {
projectId, action, note: note.trim(),
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
})
@@ -196,7 +198,8 @@ export default function CustomerFunktionen({ customerId }) {
setConfirm(null)
setNote('')
setTextform(LEERE_TEXTFORM)
setStatus({ ok: true, text: 'Gespeichert.' })
// Abschalten der Zeiten: das Portal meldet, ob das Ticket "Ersatz-Tabelle aktualisieren" angelegt wurde (bei 'fehler' warnen)
setStatus(key === 'zeiten' && action === 'disable' ? abschaltMeldung(ersatzTicket) : { ok: true, text: 'Gespeichert.' })
} catch (err) {
setConfirm(null)
setStatus({ ok: false, text: err.message })
@@ -207,6 +210,7 @@ export default function CustomerFunktionen({ customerId }) {
}
}
const teamGespeichert = (projectId, antwort) => setProjects((list) => mergeTeam(list, projectId, antwort))
const ask = (projectId, key, action) => { setNote(''); setTextform(LEERE_TEXTFORM); setConfirm({ projectId, key, action }) }
const heute = berlinYmd(new Date())
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
@@ -228,6 +232,8 @@ export default function CustomerFunktionen({ customerId }) {
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, termine)
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, termine)
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
return (
<Fragment key={`${p.projectId}:${f.key}`}>
<div className="area-row funktionen-row">
@@ -243,6 +249,7 @@ export default function CustomerFunktionen({ customerId }) {
<span>
<Badge tone={tone}>{label}</Badge>
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>}
{zeitenText && <><br /><span className={feature.hindernis ? 'area-warn' : 'area-muted'}>{zeitenText}</span></>}
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>}
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>}
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
@@ -264,7 +271,7 @@ export default function CustomerFunktionen({ customerId }) {
</Fragment>
) : (
<Fragment>
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing }).map((action) => (
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]}
</Button>
@@ -281,6 +288,9 @@ export default function CustomerFunktionen({ customerId }) {
{braucheTextform && (
<TextformPanel termine={termine} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
)}
{hatZeitenPanel && (
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
)}
{anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
)}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react'
import { FaCopy, FaSpinner } from 'react-icons/fa6'
import { Button } from '../ui'
import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi'
import {
KEINE_ZEITEN_TEXT, ersatzKnoepfe, ersatzKopieren, ersatzMeldung, navSelektorFehler, teamBody, teamFormular, teamGeaendert,
teamSpeichernFehler,
} from '../../lib/zeitenView'
// Panel "Öffnungszeiten live" unter der Zeile im Reiter "Funktionen": Team-Einstellungen der Website-Leisten (nur das Team ändert sie)
// und "Ersatz-HTML kopieren" je Plan. Entscheidungen, Texte und Prüfungen stehen in zeitenView (rein, getestet); hier wird nur verdrahtet.
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (area-row funktionen-panel) liegt als eigene Zeile außerhalb
// des Fünf-Spalten-Rasters, wie die Einbauanleitung des Terminplaners.
export default function ZeitenPanel({ customerId, projectId, feature, onSaved, onReload }) {
const start = teamFormular(feature)
const [form, setForm] = useState(start)
const [busy, setBusy] = useState(false)
const [meldung, setMeldung] = useState(null) // { ok, text, neuLaden? } zum Speichern
const [ersatz, setErsatz] = useState({ kurz: null, busy: false, meldung: null, html: '' })
// Neuer Stand vom Server (nach dem Speichern oder Neuladen): Formular auf die gespeicherten Werte setzen
useEffect(() => {
setForm({ navSelektor: start.navSelektor, kontaktleisteAus: start.kontaktleisteAus, hinweisleisteAus: start.hinweisleisteAus })
}, [feature.teamVersion, start.navSelektor, start.kontaktleisteAus, start.hinweisleisteAus])
// Eine Eingabe macht "Gespeichert." und alte Fehler unpassend; die Aufforderung zum Neuladen bleibt, bis sie befolgt wird
const aendern = (teil) => {
setMeldung((m) => (m?.neuLaden ? m : null))
setForm((f) => ({ ...f, ...teil }))
}
const selektorFehler = navSelektorFehler(form.navSelektor)
const geaendert = teamGeaendert(form, feature)
const speichern = async (event) => {
event.preventDefault()
if (selektorFehler || !geaendert) return
setBusy(true)
setMeldung(null)
try {
const antwort = await patchZeitenTeam(customerId, teamBody({ projectId, teamVersion: feature.teamVersion, ...form }))
onSaved(projectId, antwort)
setMeldung({ ok: true, text: 'Gespeichert.' })
} catch (err) {
const { text, neuLaden } = teamSpeichernFehler(err)
setMeldung({ ok: false, text, neuLaden })
} finally {
setBusy(false)
}
}
const kopieren = async ({ kurz, bezeichnung }) => {
setErsatz({ kurz, busy: true, meldung: null, html: '' })
try {
const { html } = await getZeitenErsatz(customerId, projectId, kurz)
const ergebnis = await ersatzKopieren(html, typeof navigator === 'undefined' ? undefined : navigator.clipboard)
setErsatz({ kurz, busy: false, meldung: ersatzMeldung(ergebnis, feature.plaene?.length > 1 ? bezeichnung : ''), html: ergebnis === 'manuell' ? html : '' })
} catch (err) {
setErsatz({ kurz, busy: false, meldung: { ok: false, text: err?.message || 'Ersatz-HTML konnte nicht geladen werden.' }, html: '' })
}
}
const knoepfe = ersatzKnoepfe(feature)
return (
<div className="area-row funktionen-panel funktionen-panel-zeiten">
<div className="funktionen-panel-head">
<strong>Öffnungszeiten live</strong>
<span className="area-muted">Einstellungen des Teams (der Kunde sieht und ändert sie nicht)</span>
</div>
<form onSubmit={speichern} className="funktionen-panel-form">
<label className="funktionen-panel-feld">
<span>Header-Selektor</span>
<input
className="form-control"
value={form.navSelektor}
onChange={(e) => aendern({ navSelektor: e.target.value })}
disabled={busy}
placeholder="z. B. header.site-header (leer = Standard)"
spellCheck={false}
autoComplete="off"
aria-invalid={selektorFehler ? 'true' : 'false'}
/>
</label>
<div className="area-muted">
Nur nötig, wenn die Hinweis-Leiste den festen Header der Website überlappt: CSS-Selektor des Headers.
</div>
{selektorFehler && <div className="area-error" role="alert">{selektorFehler}</div>}
<div className="area-inline">
<label className="area-inline">
<input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} />
Kontakt-Leiste aus
</label>
<label className="area-inline">
<input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} />
Hinweis-Leiste aus
</label>
</div>
<div className="area-inline">
<Button type="submit" size="sm" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
{busy ? <FaSpinner className="spinner" /> : 'Speichern'}
</Button>
{meldung && <span role="status" className={meldung.ok ? 'area-message funktionen-panel-meldung' : 'area-error funktionen-panel-meldung'}>{meldung.text}</span>}
{meldung?.neuLaden && (
<Button type="button" size="sm" variant="ghost" onClick={() => { setMeldung(null); onReload() }}>Neu laden</Button>
)}
</div>
</form>
<div className="funktionen-panel-head">
<strong>Ersatz-HTML</strong>
<span className="area-muted">Wochentabelle des Plans für den Platzhalter der Website (Einbau)</span>
</div>
{knoepfe.length === 0 ? (
<div className="area-muted">{KEINE_ZEITEN_TEXT}</div>
) : (
<div className="area-inline">
{knoepfe.map((k) => (
<Button key={k.kurz} type="button" size="sm" variant="ghost" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
{ersatz.busy && ersatz.kurz === k.kurz ? <FaSpinner className="spinner" /> : <FaCopy />} {k.label}
</Button>
))}
</div>
)}
{ersatz.meldung && <div role="status" className={ersatz.meldung.ok ? 'area-message' : 'area-error'}>{ersatz.meldung.text}</div>}
{ersatz.html && (
<textarea
readOnly
className="form-control funktionen-panel-html"
rows={8}
value={ersatz.html}
aria-label="Ersatz-HTML zum Kopieren"
onFocus={(e) => e.target.select()}
/>
)}
</div>
)
}