Ö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

+2
View File
@@ -99,6 +99,8 @@ export const DOCUMENTS = [
{ key: "leistungsbeschreibung-terminplaner", title: "Leistungsbeschreibung Terminplaner", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html" }, { key: "leistungsbeschreibung-terminplaner", title: "Leistungsbeschreibung Terminplaner", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html" },
{ key: "avv-terminplaner", title: "AVV Terminplaner (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/rechtliches/avv-terminplaner.html" }, { key: "avv-terminplaner", title: "AVV Terminplaner (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/rechtliches/avv-terminplaner.html" },
{ key: "widerrufsbelehrung-funktionen", title: "Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)", liveUrl: "https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html" }, { key: "widerrufsbelehrung-funktionen", title: "Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)", liveUrl: "https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html" },
// Oeffnungszeiten live: Schluessel hoechstens 36 Zeichen (WOMS-Grenze fuer Dokument-IDs); die Fassungen (PDF/HTML) laedt der Inhaber hoch
{ key: "leistungsbeschreibung-zeiten", title: "Leistungsbeschreibung Öffnungszeiten live", liveUrl: "https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html" },
].map((d, i) => ({ ...d, sort: i + 1 })); ].map((d, i) => ({ ...d, sort: i + 1 }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+13 -3
View File
@@ -10,6 +10,8 @@ import {
} from '../../lib/funktionenView' } from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView' import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd } from '../../lib/format' import { berlinYmd } from '../../lib/format'
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
import ZeitenPanel from './ZeitenPanel'
import '../../styles/admin-areas.css' import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen") // 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) setStatus(null)
try { try {
const mitTextform = textformNoetig(key, action, termine) 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(), projectId, action, note: note.trim(),
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}), ...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
}) })
@@ -196,7 +198,8 @@ export default function CustomerFunktionen({ customerId }) {
setConfirm(null) setConfirm(null)
setNote('') setNote('')
setTextform(LEERE_TEXTFORM) 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) { } catch (err) {
setConfirm(null) setConfirm(null)
setStatus({ ok: false, text: err.message }) 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 ask = (projectId, key, action) => { setNote(''); setTextform(LEERE_TEXTFORM); setConfirm({ projectId, key, action }) }
const heute = berlinYmd(new Date()) const heute = berlinYmd(new Date())
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId)) 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 anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, termine) const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, termine)
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, 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 ( return (
<Fragment key={`${p.projectId}:${f.key}`}> <Fragment key={`${p.projectId}:${f.key}`}>
<div className="area-row funktionen-row"> <div className="area-row funktionen-row">
@@ -243,6 +249,7 @@ export default function CustomerFunktionen({ customerId }) {
<span> <span>
<Badge tone={tone}>{label}</Badge> <Badge tone={tone}>{label}</Badge>
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>} {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></>} {abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>}
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</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)} /></>} {siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
@@ -264,7 +271,7 @@ export default function CustomerFunktionen({ customerId }) {
</Fragment> </Fragment>
) : ( ) : (
<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)}> <Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]} {AKTION_LABEL[action]}
</Button> </Button>
@@ -281,6 +288,9 @@ export default function CustomerFunktionen({ customerId }) {
{braucheTextform && ( {braucheTextform && (
<TextformPanel termine={termine} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} /> <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 && ( {anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} /> <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>
)
}
+3
View File
@@ -60,6 +60,9 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
// Nach dem Festschreiben (502) liegt die Rechnung schon als "offen" vor // Nach dem Festschreiben (502) liegt die Rechnung schon als "offen" vor
if (info.invoiceId) err.invoiceId = info.invoiceId if (info.invoiceId) err.invoiceId = info.invoiceId
if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber
// Team-Einstellungen der Oeffnungszeiten (PATCH): code (409 ZEITEN_VERALTET) und fehler je Feld (400)
if (typeof info.code === 'string') err.code = info.code
if (info.fehler && typeof info.fehler === 'object') err.fehler = info.fehler
throw err throw err
} }
return data ?? {} return data ?? {}
+8
View File
@@ -2,6 +2,7 @@ import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest' import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth' import { createJwtCache } from './serverpulsAuth'
import { anleitungPfad, featureBody } from './funktionenView' import { anleitungPfad, featureBody } from './funktionenView'
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
/** /**
* Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer). * Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer).
@@ -103,6 +104,13 @@ export const setNewsFeature = (customerId, body) => setFeature(customerId, 'news
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */ /** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId)) export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId))
// --- Oeffnungszeiten live (Funktion zeiten): Ersatz-HTML und Team-Einstellungen ---
/** Ersatz-HTML (Wochentabelle) eines Plans fuer den Einbau: { html }; 404 unbekannter Plan, 409 "Der Kunde hat noch keine Zeiten eingetragen." */
export const getZeitenErsatz = (customerId, projectId, plan) => request(ersatzPfad(customerId, projectId, plan))
/** Team-Einstellungen (Rumpf: teamBody in zeitenView): { team, teamVersion }; 400 ungueltig, 409 veraltet (err.code ZEITEN_VERALTET). PATCH, weil das Ticketsystem kein PUT durchlaesst. */
export const patchZeitenTeam = (customerId, body) => request(zeitenTeamPfad(customerId), { method: 'PATCH', body })
// --- Abo-Status (alle Mitarbeiter) --- // --- Abo-Status (alle Mitarbeiter) ---
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */ /** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
+109
View File
@@ -75,3 +75,112 @@ export function teamBody({ projectId, teamVersion, navSelektor, kontaktleisteAus
hinweisleisteAus: hinweisleisteAus === true, hinweisleisteAus: hinweisleisteAus === true,
} }
} }
// --- Panel unter der Zeile "Oeffnungszeiten live": Pfade der Team-Routen, Formular, Speichern, Ersatz-HTML, Meldung nach dem Abschalten ---
const enc = encodeURIComponent
/** Pfad (relativ zu /api/team) von GET .../zeiten/ersatz; ohne plan faellt der Parameter weg (Hauptplan), ein leerer plan bleibt stehen (Portal: 404). */
export const ersatzPfad = (customerId, projectId, plan) =>
`/funktionen/${enc(customerId)}/zeiten/ersatz?projectId=${enc(projectId)}${plan === undefined || plan === null ? '' : `&plan=${enc(plan)}`}`
/** Pfad (relativ zu /api/team) von PATCH .../zeiten/team. */
export const zeitenTeamPfad = (customerId) => `/funktionen/${enc(customerId)}/zeiten/team`
/** Startwerte des Team-Formulars aus feature.team: Selektor als Text (null => leeres Feld), Schalter nur bei true an. */
export function teamFormular(feature) {
const team = feature?.team
return {
navSelektor: typeof team?.navSelektor === 'string' ? team.navSelektor : '',
kontaktleisteAus: team?.kontaktleisteAus === true,
hinweisleisteAus: team?.hinweisleisteAus === true,
}
}
/** true, wenn das Formular vom gespeicherten Stand (feature.team) abweicht; Leerraum um den Selektor zaehlt nicht. */
export function teamGeaendert(form, feature) {
const start = teamFormular(feature)
return (
String(form?.navSelektor ?? '').trim() !== start.navSelektor.trim() ||
(form?.kontaktleisteAus === true) !== start.kontaktleisteAus ||
(form?.hinweisleisteAus === true) !== start.hinweisleisteAus
)
}
/**
* Antwort des Team-PATCH ({ team, teamVersion }) in die Projektliste einarbeiten: nur team und teamVersion von features.zeiten dieses
* Projekts; alles andere bleibt. Eine unbrauchbare Antwort oder ein Projekt ohne Zeiten aendert nichts (gleiche Liste zurueck).
*/
export function mergeTeam(projects, projectId, antwort) {
const { team, teamVersion } = antwort || {}
if (!team || typeof team !== 'object' || !Number.isInteger(teamVersion)) return projects
return projects.map((p) => (p.projectId === projectId && p.features?.zeiten ? { ...p, features: { ...p.features, zeiten: { ...p.features.zeiten, team, teamVersion } } } : p))
}
const VERALTET_TEXT = 'Inzwischen geändert – bitte neu laden.'
/**
* Fehler des Team-PATCH -> { text, neuLaden }. 409 ZEITEN_VERALTET: "Inzwischen geändert – bitte neu laden." (die Ansicht ist veraltet,
* neuLaden); jeder andere 409 (z. B. Funktion nicht eingeschaltet) zeigt den Text des Portals und gilt ebenfalls als veraltet. Alles andere
* (400, Netz) zeigt seinen Text unveraendert.
*/
export function teamSpeichernFehler(err) {
if (err?.status === 409) return { text: err.code === 'ZEITEN_VERALTET' ? VERALTET_TEXT : err.message || VERALTET_TEXT, neuLaden: true }
return { text: err?.message || 'Speichern fehlgeschlagen.', neuLaden: false }
}
/** Text des Portals (409 von GET .../zeiten/ersatz), auch als Hinweis statt Knopf, solange feature.plaene leer ist. */
export const KEINE_ZEITEN_TEXT = 'Der Kunde hat noch keine Zeiten eingetragen.'
/**
* Knoepfe "Ersatz-HTML kopieren" je Plan aus feature.plaene (Portal: [{ kurz, name, haupt }], Hauptplan zuerst): { kurz, label, bezeichnung }.
* Ein Plan: schlicht "Ersatz-HTML kopieren"; mehrere: mit "Name (kurz)" dahinter. Plaene ohne Kurznamen werden uebergangen.
*/
export function ersatzKnoepfe(feature) {
const plaene = (Array.isArray(feature?.plaene) ? feature.plaene : []).filter((p) => p && typeof p.kurz === 'string' && p.kurz !== '')
return plaene.map((p) => {
const bezeichnung = typeof p.name === 'string' && p.name !== '' && p.name !== p.kurz ? `${p.name} (${p.kurz})` : p.kurz
return { kurz: p.kurz, label: plaene.length > 1 ? `Ersatz-HTML kopieren: ${bezeichnung}` : 'Ersatz-HTML kopieren', bezeichnung }
})
}
/**
* Schreibt das Ersatz-HTML in die Zwischenablage (clipboard = navigator.clipboard). 'kopiert'; 'manuell', wenn es die API nicht gibt oder der
* Browser das Schreiben ablehnt (dann zeigt das Panel das HTML zum Markieren); 'leer' ohne Text (nie eine leere Zwischenablage melden).
*/
export async function ersatzKopieren(html, clipboard) {
if (typeof html !== 'string' || html === '') return 'leer'
if (!clipboard || typeof clipboard.writeText !== 'function') return 'manuell'
try {
await clipboard.writeText(html)
return 'kopiert'
} catch {
return 'manuell'
}
}
export const ZWISCHENABLAGE_FEHLT = 'Die Zwischenablage ist in diesem Browser nicht verfügbar. Bitte das HTML unten markieren und von Hand kopieren.'
/** Sichtbare Rueckmeldung zum Kopieren: { ok, text }; bezeichnung (Plan) wird bei mehreren Plaenen genannt. */
export function ersatzMeldung(ergebnis, bezeichnung = '') {
if (ergebnis === 'kopiert') return { ok: true, text: bezeichnung ? `Ersatz-HTML kopiert: ${bezeichnung}.` : 'Ersatz-HTML kopiert.' }
if (ergebnis === 'manuell') return { ok: false, text: ZWISCHENABLAGE_FEHLT }
return { ok: false, text: 'Es gibt kein Ersatz-HTML zum Kopieren.' }
}
/**
* Meldung nach dem Abschalten von Oeffnungszeiten live aus ersatzTicket der Portal-Antwort: 'angelegt' (Ticket "Ersatz-Tabelle aktualisieren"),
* 'aktuell' (nichts zu tun), 'fehler' (Ticket nicht angelegt: Warnung, denn ein zweites Abschalten gibt es nicht, das Team legt es von Hand an).
* Ohne oder mit unbekanntem Wert schlicht "Gespeichert.".
*/
export function abschaltMeldung(ersatzTicket) {
if (ersatzTicket === 'angelegt') return { ok: true, text: 'Gespeichert. Ticket „Ersatz-Tabelle aktualisieren“ angelegt.' }
if (ersatzTicket === 'aktuell') return { ok: true, text: 'Gespeichert. Die Ersatz-Tabelle ist aktuell, kein Ticket nötig.' }
if (ersatzTicket === 'fehler') {
return {
ok: false,
text: 'Gespeichert, aber das Ticket „Ersatz-Tabelle aktualisieren“ konnte nicht angelegt werden. Bitte von Hand anlegen: Die feste Ersatz-Tabelle der Website muss zum Wochenplan des Kunden passen (Ersatz-HTML dazu im Panel unter der Zeile kopieren).',
}
}
return { ok: true, text: 'Gespeichert.' }
}
+4
View File
@@ -34,6 +34,10 @@
.funktionen-panel-head { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); align-items: center; } .funktionen-panel-head { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); align-items: center; }
.funktionen-panel-warnungen { margin: 0; padding-left: 32px; } .funktionen-panel-warnungen { margin: 0; padding-left: 32px; }
.funktionen-panel .area-summary { margin-bottom: 0; } .funktionen-panel .area-summary { margin-bottom: 0; }
.funktionen-panel-zeiten .area-error, .funktionen-panel-zeiten .area-message { margin: 0; }
.funktionen-panel-form { display: flex; flex-direction: column; gap: var(--sp-2); }
.funktionen-panel-feld { display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
.funktionen-panel-html { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 120px; }
.funktionen-panel-abschnitt { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-1); min-width: 0; } .funktionen-panel-abschnitt { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-1); min-width: 0; }
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; } .funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
.funktionen-panel-abschnitt pre { .funktionen-panel-abschnitt pre {
+21 -2
View File
@@ -62,17 +62,35 @@ test('terminplaner_karten_vorhanden', () => {
assert.ok(d.liveUrl.startsWith(base)) assert.ok(d.liveUrl.startsWith(base))
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum') assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
} }
assert.equal(DOCUMENTS.length, 9, 'keine Analyse-Karten und keine weiteren') // Oeffnungszeiten live: Karte 10, hinter den Terminplaner-Karten; der Schluessel hat 28 Zeichen (WOMS-Grenze 36, Ruling R35)
assert.equal(DOCUMENTS.length, 10, 'keine Analyse-Karten und keine weiteren')
})
test('zeiten_karte_vorhanden_am_ende', () => {
const d = DOCUMENTS.find((x) => x.key === 'leistungsbeschreibung-zeiten')
assert.ok(d, 'Karte Leistungsbeschreibung Oeffnungszeiten live')
assert.deepEqual(d, {
key: 'leistungsbeschreibung-zeiten',
title: 'Leistungsbeschreibung Öffnungszeiten live',
liveUrl: 'https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html',
sort: 10,
})
assert.ok(d.key.length <= 36, 'Dokument-ID hoechstens 36 Zeichen')
assert.equal(DOCUMENTS.at(-1), d, 'am Ende von DOCUMENTS, nach den Terminplaner-Karten')
assert.equal(DOCUMENTS.findIndex((x) => x.key === 'widerrufsbelehrung-funktionen'), 8, 'Terminplaner-Karten unveraendert davor')
}) })
test('bestehende_karten_unveraendert', () => { test('bestehende_karten_unveraendert', () => {
assert.deepEqual(DOCUMENTS.slice(0, 6), [ assert.deepEqual(DOCUMENTS.slice(0, 9), [
{ key: 'agb', title: 'AGB', liveUrl: 'https://webklar.com/agb', sort: 1 }, { key: 'agb', title: 'AGB', liveUrl: 'https://webklar.com/agb', sort: 1 },
{ key: 'abo-bedingungen', title: 'Abo-Bedingungen', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 2 }, { key: 'abo-bedingungen', title: 'Abo-Bedingungen', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 2 },
{ key: 'avv', title: 'AVV (Auftragsverarbeitung)', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 3 }, { key: 'avv', title: 'AVV (Auftragsverarbeitung)', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 3 },
{ key: 'datenschutz', title: 'Datenschutzerklärung (Website und Portal)', liveUrl: 'https://project.webklar.com/datenschutz.html', sort: 4 }, { key: 'datenschutz', title: 'Datenschutzerklärung (Website und Portal)', liveUrl: 'https://project.webklar.com/datenschutz.html', sort: 4 },
{ key: 'impressum', title: 'Impressum', liveUrl: 'https://webklar.com/impressum', sort: 5 }, { key: 'impressum', title: 'Impressum', liveUrl: 'https://webklar.com/impressum', sort: 5 },
{ key: 'widerruf', title: 'Widerrufsbelehrung und -formular', liveUrl: 'https://project.webklar.com/widerrufen.html', sort: 6 }, { key: 'widerruf', title: 'Widerrufsbelehrung und -formular', liveUrl: 'https://project.webklar.com/widerrufen.html', sort: 6 },
{ key: 'leistungsbeschreibung-terminplaner', title: 'Leistungsbeschreibung Terminplaner', liveUrl: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html', sort: 7 },
{ key: 'avv-terminplaner', title: 'AVV Terminplaner (Auftragsverarbeitung)', liveUrl: 'https://project.webklar.com/rechtliches/avv-terminplaner.html', sort: 8 },
{ key: 'widerrufsbelehrung-funktionen', title: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', liveUrl: 'https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html', sort: 9 },
]) ])
}) })
@@ -88,5 +106,6 @@ test('dry_run_listet_karten', () => {
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-terminplaner:'))) assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-terminplaner:'))) assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:'))) assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:')))
assert.ok(zeilen.includes('Rechtstext leistungsbeschreibung-zeiten: Leistungsbeschreibung Öffnungszeiten live'))
assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an') assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an')
}) })
+19
View File
@@ -125,3 +125,22 @@ test('401 des Portals: einmal mit frischem JWT wiederholen; scheitert dann creat
assert.deepEqual(await retry.api('/uebersicht'), { ok: true }) assert.deepEqual(await retry.api('/uebersicht'), { ok: true })
assert.deepEqual(retry.calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2']) assert.deepEqual(retry.calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
}) })
test('Fehlercode und Feldmeldungen des Portals bleiben am Fehler (409 ZEITEN_VERALTET, 400 mit fehler)', async () => {
const { api } = client({
responses: [
reply(409, { error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }),
reply(400, { error: 'Bitte prüfen.', fehler: { navSelektor: 'zu lang' } }),
reply(409, { error: 'nur Text' }),
],
})
const veraltet = await fails(api('/x', { method: 'PATCH', body: {} }))
assert.equal(veraltet.status, 409)
assert.equal(veraltet.code, 'ZEITEN_VERALTET')
const ungueltig = await fails(api('/x', { method: 'PATCH', body: {} }))
assert.equal(ungueltig.status, 400)
assert.deepEqual(ungueltig.fehler, { navSelektor: 'zu lang' })
const ohne = await fails(api('/x'))
assert.equal(ohne.code, undefined)
assert.equal(ohne.fehler, undefined)
})
+181 -1
View File
@@ -3,7 +3,11 @@ import assert from 'node:assert/strict'
import { loadModule } from './load.mjs' import { loadModule } from './load.mjs'
// Öffnungszeiten live im Funktionen-Reiter: Statustexte, Prüfung des Navigations-Selektors (Spiegel des Portals) und Rumpf der Team-Einstellungen // Öffnungszeiten live im Funktionen-Reiter: Statustexte, Prüfung des Navigations-Selektors (Spiegel des Portals) und Rumpf der Team-Einstellungen
const { zeitenStatusText, navSelektorFehler, NAV_SELEKTOR_MAX, NAV_SELEKTOR_FEHLER, teamBody } = await loadModule('src/lib/zeitenView.js') const {
zeitenStatusText, navSelektorFehler, NAV_SELEKTOR_MAX, NAV_SELEKTOR_FEHLER, teamBody,
ersatzPfad, zeitenTeamPfad, teamFormular, teamGeaendert, mergeTeam, teamSpeichernFehler,
ersatzKnoepfe, ersatzKopieren, ersatzMeldung, abschaltMeldung, KEINE_ZEITEN_TEXT, ZWISCHENABLAGE_FEHLT,
} = await loadModule('src/lib/zeitenView.js')
const EINBAU = 'Leisten live – Einbau läuft' const EINBAU = 'Leisten live – Einbau läuft'
const WARTET = 'wartet auf die Zeiten des Kunden' const WARTET = 'wartet auf die Zeiten des Kunden'
@@ -152,3 +156,179 @@ test('teamBody: die Version wird unverändert durchgereicht (das Portal prüft s
test('teamBody: ein ungültiger Selektor wird nicht "repariert", sondern unverändert gesendet (das Formular sperrt vorher, das Portal prüft noch einmal)', () => { test('teamBody: ein ungültiger Selektor wird nicht "repariert", sondern unverändert gesendet (das Formular sperrt vorher, das Portal prüft noch einmal)', () => {
assert.equal(teamBody({ projectId: 'p1', teamVersion: 1, navSelektor: 'a{b}', kontaktleisteAus: false, hinweisleisteAus: false }).navSelektor, 'a{b}') assert.equal(teamBody({ projectId: 'p1', teamVersion: 1, navSelektor: 'a{b}', kontaktleisteAus: false, hinweisleisteAus: false }).navSelektor, 'a{b}')
}) })
// --- Panel: Pfade, Formular, Speichern, Ersatz-HTML, Abschalt-Meldung ---
test('ersatzPfad: GET .../zeiten/ersatz mit kodierten Parametern projectId und plan', () => {
assert.equal(ersatzPfad('c1', 'p1', 'haupt'), '/funktionen/c1/zeiten/ersatz?projectId=p1&plan=haupt')
assert.equal(ersatzPfad('c/1', 'p 1', 'a&b=c'), '/funktionen/c%2F1/zeiten/ersatz?projectId=p%201&plan=a%26b%3Dc')
})
test('ersatzPfad: ohne plan fehlt der Parameter (Hauptplan), ein leerer plan bleibt stehen (das Portal antwortet dann 404)', () => {
assert.equal(ersatzPfad('c1', 'p1'), '/funktionen/c1/zeiten/ersatz?projectId=p1')
assert.equal(ersatzPfad('c1', 'p1', null), '/funktionen/c1/zeiten/ersatz?projectId=p1')
assert.equal(ersatzPfad('c1', 'p1', ''), '/funktionen/c1/zeiten/ersatz?projectId=p1&plan=')
})
test('zeitenTeamPfad: PATCH .../zeiten/team', () => {
assert.equal(zeitenTeamPfad('c1'), '/funktionen/c1/zeiten/team')
assert.equal(zeitenTeamPfad('c/1'), '/funktionen/c%2F1/zeiten/team')
})
test('teamFormular: Startwerte aus feature.team, null-Selektor wird ein leeres Feld', () => {
assert.deepEqual(
teamFormular({ team: { navSelektor: 'header > nav', kontaktleisteAus: true, hinweisleisteAus: false } }),
{ navSelektor: 'header > nav', kontaktleisteAus: true, hinweisleisteAus: false }
)
assert.deepEqual(
teamFormular({ team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: true } }),
{ navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: true }
)
})
test('teamFormular: ohne Team-Feld (ältere Antwort, keine Ansicht) alles aus und leer', () => {
const leer = { navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: false }
assert.deepEqual(teamFormular({}), leer)
assert.deepEqual(teamFormular({ team: null }), leer)
assert.deepEqual(teamFormular(null), leer)
assert.deepEqual(teamFormular(undefined), leer)
// Schalter nur bei true an, Selektor nur als Text
assert.deepEqual(teamFormular({ team: { navSelektor: 5, kontaktleisteAus: 'true', hinweisleisteAus: 1 } }), leer)
})
test('teamGeaendert: nur ein echter Unterschied zum gespeicherten Stand zählt, Leerraum um den Selektor nicht', () => {
const feature = { team: { navSelektor: 'header', kontaktleisteAus: false, hinweisleisteAus: true } }
const gleich = { navSelektor: 'header', kontaktleisteAus: false, hinweisleisteAus: true }
assert.equal(teamGeaendert(gleich, feature), false)
assert.equal(teamGeaendert({ ...gleich, navSelektor: ' header ' }, feature), false)
assert.equal(teamGeaendert({ ...gleich, navSelektor: 'header nav' }, feature), true)
assert.equal(teamGeaendert({ ...gleich, kontaktleisteAus: true }, feature), true)
assert.equal(teamGeaendert({ ...gleich, hinweisleisteAus: false }, feature), true)
// gespeichert ohne Selektor: leeres oder leeres-mit-Leerraum Feld ist keine Änderung
const ohne = { team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false } }
assert.equal(teamGeaendert({ navSelektor: ' ', kontaktleisteAus: false, hinweisleisteAus: false }, ohne), false)
assert.equal(teamGeaendert({ navSelektor: 'x', kontaktleisteAus: false, hinweisleisteAus: false }, ohne), true)
})
test('mergeTeam: übernimmt team und teamVersion in genau dieses Projekt, alles andere bleibt', () => {
const zeiten = { state: 'active', plaene: [{ kurz: 'a', name: 'A', haupt: true }], team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }, teamVersion: 2 }
const projects = [
{ projectId: 'p1', projectName: 'Eins', features: { news: { state: 'off' }, zeiten } },
{ projectId: 'p2', projectName: 'Zwei', features: { zeiten: { ...zeiten, teamVersion: 7 } } },
]
const neu = mergeTeam(projects, 'p1', { team: { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 3 })
assert.notEqual(neu, projects, 'neue Liste')
assert.deepEqual(neu[0].features.zeiten, { ...zeiten, team: { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 3 })
assert.deepEqual(neu[0].features.news, { state: 'off' })
assert.equal(neu[0].projectName, 'Eins')
assert.equal(neu[1], projects[1], 'anderes Projekt unverändert')
assert.equal(projects[0].features.zeiten.teamVersion, 2, 'Eingabe nicht verändert')
})
test('mergeTeam: unbrauchbare Antwort oder Projekt ohne Zeiten ändert nichts', () => {
const projects = [{ projectId: 'p1', features: { zeiten: { state: 'active', teamVersion: 1, team: {} } } }, { projectId: 'p2', features: {} }]
assert.equal(mergeTeam(projects, 'p1', null), projects)
assert.equal(mergeTeam(projects, 'p1', {}), projects)
assert.equal(mergeTeam(projects, 'p1', { team: { navSelektor: null }, teamVersion: 'x' }), projects)
assert.equal(mergeTeam(projects, 'p1', { teamVersion: 2 }), projects)
assert.deepEqual(mergeTeam(projects, 'p2', { team: {}, teamVersion: 1 }), projects, 'p2 hat keine Zeiten: nichts erfunden')
assert.deepEqual(mergeTeam(projects, 'p9', { team: {}, teamVersion: 1 }), projects)
})
test('teamSpeichernFehler: 409 ZEITEN_VERALTET – „Inzwischen geändert – bitte neu laden“, danach neu laden', () => {
const err = Object.assign(new Error('Inzwischen geändert – bitte neu laden.'), { status: 409, code: 'ZEITEN_VERALTET' })
assert.deepEqual(teamSpeichernFehler(err), { text: 'Inzwischen geändert – bitte neu laden.', neuLaden: true })
// der feste Text gilt auch, wenn der Server-Text anders lautet
const anders = Object.assign(new Error('irgendwas'), { status: 409, code: 'ZEITEN_VERALTET' })
assert.equal(teamSpeichernFehler(anders).text, 'Inzwischen geändert – bitte neu laden.')
})
test('teamSpeichernFehler: anderer 409 (z. B. nicht eingeschaltet) zeigt den Text des Portals, die Ansicht ist trotzdem veraltet', () => {
const err = Object.assign(new Error('Öffnungszeiten live ist für dieses Projekt nicht eingeschaltet.'), { status: 409 })
assert.deepEqual(teamSpeichernFehler(err), { text: 'Öffnungszeiten live ist für dieses Projekt nicht eingeschaltet.', neuLaden: true })
})
test('teamSpeichernFehler: 400 und Netzfehler zeigen ihren Text unverändert, ohne Neuladen; ohne Text eine Ersatzmeldung', () => {
assert.deepEqual(teamSpeichernFehler(Object.assign(new Error(NAV_SELEKTOR_FEHLER), { status: 400 })), { text: NAV_SELEKTOR_FEHLER, neuLaden: false })
assert.deepEqual(teamSpeichernFehler(Object.assign(new Error('Das Kundenportal ist gerade nicht erreichbar.'), { status: 0 })), { text: 'Das Kundenportal ist gerade nicht erreichbar.', neuLaden: false })
assert.deepEqual(teamSpeichernFehler(new Error('')), { text: 'Speichern fehlgeschlagen.', neuLaden: false })
assert.deepEqual(teamSpeichernFehler(null), { text: 'Speichern fehlgeschlagen.', neuLaden: false })
})
test('ersatzKnoepfe: ein Plan – „Ersatz-HTML kopieren“, der Hauptplan zuerst bleibt in der Reihenfolge des Portals', () => {
assert.deepEqual(ersatzKnoepfe({ plaene: [{ kurz: 'haupt', name: 'Öffnungszeiten', haupt: true }] }), [
{ kurz: 'haupt', label: 'Ersatz-HTML kopieren', bezeichnung: 'Öffnungszeiten (haupt)' },
])
})
test('ersatzKnoepfe: mehrere Pläne – je Plan ein Knopf mit Name und Kurzname', () => {
const knoepfe = ersatzKnoepfe({
plaene: [
{ kurz: 'laden', name: 'Laden', haupt: true },
{ kurz: 'cafe', name: 'Café', haupt: false },
{ kurz: 'lieferung', name: '', haupt: false },
{ kurz: 'abholung', name: 'abholung', haupt: false },
],
})
assert.deepEqual(knoepfe.map((k) => k.kurz), ['laden', 'cafe', 'lieferung', 'abholung'])
assert.deepEqual(knoepfe.map((k) => k.label), [
'Ersatz-HTML kopieren: Laden (laden)',
'Ersatz-HTML kopieren: Café (cafe)',
'Ersatz-HTML kopieren: lieferung',
'Ersatz-HTML kopieren: abholung',
])
})
test('ersatzKnoepfe: Pläne ohne Kurzname und Unbrauchbares werden übergangen, ohne Pläne [] (Hinweis statt Knopf)', () => {
assert.deepEqual(ersatzKnoepfe({ plaene: [{ kurz: '', name: 'x' }, null, { name: 'y' }, { kurz: 5 }] }), [])
assert.deepEqual(ersatzKnoepfe({ plaene: [] }), [])
assert.deepEqual(ersatzKnoepfe({}), [])
assert.deepEqual(ersatzKnoepfe(null), [])
assert.equal(KEINE_ZEITEN_TEXT, 'Der Kunde hat noch keine Zeiten eingetragen.')
})
test('ersatzKopieren: schreibt das HTML in die Zwischenablage', async () => {
const geschrieben = []
const clipboard = { writeText: async (text) => { geschrieben.push(text) } }
assert.equal(await ersatzKopieren('<table>&auml;</table>', clipboard), 'kopiert')
assert.deepEqual(geschrieben, ['<table>&auml;</table>'])
})
test('ersatzKopieren: ohne Zwischenablage-API oder bei Ablehnung – manuell kopieren; leeres HTML wird nie kopiert', async () => {
assert.equal(await ersatzKopieren('<p>x</p>', undefined), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', null), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', {}), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', { writeText: 'kein Funktion' }), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', { writeText: async () => { throw new Error('NotAllowedError') } }), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', { writeText: () => { throw new Error('sofort') } }), 'manuell')
let aufgerufen = 0
const clipboard = { writeText: async () => { aufgerufen++ } }
for (const leer of ['', undefined, null, 5]) assert.equal(await ersatzKopieren(leer, clipboard), 'leer', String(leer))
assert.equal(aufgerufen, 0)
})
test('ersatzMeldung: bestätigt das Kopieren sichtbar, nennt den Plan und warnt, wenn die Zwischenablage fehlt', () => {
assert.deepEqual(ersatzMeldung('kopiert'), { ok: true, text: 'Ersatz-HTML kopiert.' })
assert.deepEqual(ersatzMeldung('kopiert', 'Café (cafe)'), { ok: true, text: 'Ersatz-HTML kopiert: Café (cafe).' })
assert.deepEqual(ersatzMeldung('manuell'), { ok: false, text: ZWISCHENABLAGE_FEHLT })
assert.match(ZWISCHENABLAGE_FEHLT, /Zwischenablage/)
assert.equal(ersatzMeldung('leer').ok, false)
assert.equal(ersatzMeldung('irgendwas').ok, false)
})
test('abschaltMeldung: angelegt, aktuell, fehler und ohne Angabe', () => {
assert.deepEqual(abschaltMeldung('angelegt'), { ok: true, text: 'Gespeichert. Ticket „Ersatz-Tabelle aktualisieren“ angelegt.' })
assert.deepEqual(abschaltMeldung('aktuell'), { ok: true, text: 'Gespeichert. Die Ersatz-Tabelle ist aktuell, kein Ticket nötig.' })
assert.deepEqual(abschaltMeldung(undefined), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(abschaltMeldung('etwas-neues'), { ok: true, text: 'Gespeichert.' })
})
test('abschaltMeldung: bei „fehler“ eine sichtbare Warnung – Ticket von Hand anlegen, ein zweites Abschalten gibt es nicht', () => {
const m = abschaltMeldung('fehler')
assert.equal(m.ok, false)
assert.match(m.text, /^Gespeichert, aber/)
assert.match(m.text, /„Ersatz-Tabelle aktualisieren“/)
assert.match(m.text, /konnte nicht angelegt werden/)
assert.match(m.text, /von Hand/)
assert.doesNotMatch(m.text, /24 Uhr/)
})