Compare commits

...
6 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 35e2761e68 Merge origin/test (WOMS-Überarbeitung Planboard/Tickets) in feature/oeffnungszeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:56:02 +00:00
ClaudeandClaude Opus 5.5 f67192db52 Funktionen: K16-Warnung nennt bei den Öffnungszeiten keinen AVV
Die Warnung "K16 ging nicht raus" sprach von Leistungsbeschreibung und AVV. Öffnungszeiten live hat keinen AVV und
schickt keine Anhänge mit, die Warnung behauptet es dort jetzt nicht mehr: speicherStatus(antwort, key) liefert für
zeiten den Text K16_WARNUNG_OHNE_UNTERLAGEN, für alle anderen Funktionen unverändert K16_WARNUNG. Test für beide Wege.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:19:55 +00:00
ClaudeandClaude Opus 5.5 8319084930 Merge remote-tracking branch 'origin/test' into feature/oeffnungszeiten
Zusammenführung von TP2 und Webshop im Ticketsystem (origin/test 00fa229) mit Öffnungszeiten live. Die Funktionen
stehen in der Reihenfolge news, termine, shop, zeiten.

Konfliktdateien (6, erwartet war nur CustomerFunktionen.jsx; origin/test enthält seit der Vorbereitung auch die
beiden Webshop-Commits 0a2eab8 und 00fa229): scripts/setup-legal.mjs, src/components/customer/CustomerFunktionen.jsx,
src/lib/funktionenView.js, src/lib/teamApi.js, tests/setup-legal.test.mjs, tests/unit/funktionenView.test.mjs.

Entscheidungen: ersatzTicket-Behandlung der Zeiten und speicherStatus/K16_WARNUNG von TP2 bleiben beide in save()
(Abschalten der Zeiten zeigt abschaltMeldung, jede andere Aktion speicherStatus); Rechtliches-Karten Webshop (sort 10, 11)
vor der Karte der Öffnungszeiten (sort 12, DOCUMENTS hat 12 Einträge); siteKeyOf kennt termine, shop und zeiten; aktionen
behandelt shop und zeiten wie den Terminplaner (ticketbasiert, Abnahme der Zeiten mit hindernis); teamApi trägt Webshop-
und Zeiten-Aufrufe nebeneinander.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:19:42 +00:00
ClaudeandClaude Opus 5.5 919d96f7a0 Öffnungszeiten: Status "Leisten live – Einbau läuft" nur bei einbau angestossen
In der Einrichtung nennt der Statustext "Leisten live – Einbau läuft" nur noch, wenn der Neubau angestossen ist.
Bei einbau manuell oder leer steht "Einbau läuft (Team setzt die Skript-Zeile von Hand)"; "wartet auf die Zeiten des Kunden" geht weiter vor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:10:45 +00:00
ClaudeandClaude Opus 5.5 3d67aa88cf Ö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>
2026-10-05 07:12:04 +00:00
ClaudeandClaude Opus 5.5 994347408d Öffnungszeiten: Register und Texte im Ticketsystem
Funktionen-Reiter kennt "Öffnungszeiten live" (zeiten) hinter dem Terminplaner:
Bestätigungstexte, Site-Key, Aktionen je Zustand (Abnahme nur ohne Hindernis
"zeiten_fehlen"). Neues Modul zeitenView.js: Statustext (Einbau läuft / wartet
auf die Zeiten des Kunden / Abo-Ende mit Frist), navSelektorFehler (Spiegel
der Portal-Regel) und teamBody für PATCH .../zeiten/team. Protokoll-Label
für zeiten_gespeichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:02:30 +00:00
14 changed files with 873 additions and 19 deletions

No files matched your search

+2
View File
@@ -102,6 +102,8 @@ export const DOCUMENTS = [
// Webshop: gleiches Muster wie beim Terminplaner (Alias-Seite ohne Fassungsdatum)
{ key: "leistungsbeschreibung-webshop", title: "Leistungsbeschreibung Webshop", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop.html" },
{ key: "avv-webshop", title: "AVV Webshop", liveUrl: "https://project.webklar.com/rechtliches/avv-webshop.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 }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+12 -2
View File
@@ -11,6 +11,8 @@ import {
} from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd, fmtDateTime } from '../../lib/format'
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
import ZeitenPanel from './ZeitenPanel'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand, Terminplaner und Webshop je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
@@ -275,7 +277,8 @@ export default function CustomerFunktionen({ customerId }) {
setConfirm(null)
setNote('')
setTextform(LEERE_TEXTFORM)
setStatus(speicherStatus(antwort))
// Abschalten der Zeiten: das Portal meldet, ob das Ticket "Ersatz-Tabelle aktualisieren" angelegt wurde (bei 'fehler' warnen)
setStatus(key === 'zeiten' && action === 'disable' ? abschaltMeldung(antwort?.ersatzTicket) : speicherStatus(antwort, key))
} catch (err) {
setConfirm(null)
setStatus({ ok: false, text: err.message })
@@ -287,6 +290,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))
@@ -308,6 +312,8 @@ export default function CustomerFunktionen({ customerId }) {
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
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">
@@ -323,6 +329,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)} /></>}
@@ -344,7 +351,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>
@@ -361,6 +368,9 @@ export default function CustomerFunktionen({ customerId }) {
{braucheTextform && (
<TextformPanel funktion={f.key} feld={felder[f.key]} 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>
)
}
+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
if (info.invoiceId) err.invoiceId = info.invoiceId
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
}
return data ?? {}
+40 -11
View File
@@ -1,4 +1,4 @@
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Webshop), Aktionen je Zustand und
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Webshop, Öffnungszeiten live), Aktionen je Zustand und
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
import { fmtYmd, fmtBerlin } from './format'
@@ -34,6 +34,16 @@ export const FUNKTIONEN = [
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'zeiten',
label: 'Öffnungszeiten live',
bestaetigen: {
activate: 'Öffnungszeiten live ohne Zahlung aktivieren? Es startet der Einbau: Das Einbau-Ticket wird angelegt und die Seite, wenn möglich, neu gebaut. Freigeschaltet wird, sobald das Ticket geschlossen ist und der Kunde seine Zeiten eingetragen hat.',
disable: 'Öffnungszeiten live abschalten? Leisten und Live-Anzeige verschwinden von der Website, die feste Ersatz-Tabelle bleibt stehen. Die Angaben des Kunden bleiben gespeichert. Weicht der Wochenplan von der Ersatz-Tabelle ab, wird ein Ticket „Ersatz-Tabelle aktualisieren“ angelegt.',
abnahme: 'Öffnungszeiten live freischalten? Der Kunde bekommt die Mail „bereit“ und der Einbau gilt als abgenommen.',
ticket: 'Fehlendes Einbau-Ticket neu anlegen?',
},
},
]
/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */
@@ -68,12 +78,12 @@ export function abgeschaltetText(feature) {
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
/**
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und beim Webshop und nur, wenn das Portal
* einen nennt (feature.siteKey); sonst ''.
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner, beim Webshop und bei Oeffnungszeiten live
* und nur, wenn das Portal einen nennt (feature.siteKey); sonst ''.
*/
export function siteKeyOf(feature, key) {
const wert = feature?.siteKey
return (key === 'termine' || key === 'shop') && typeof wert === 'string' ? wert.trim() : ''
return (key === 'termine' || key === 'shop' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
}
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
@@ -86,13 +96,20 @@ export const AKTION_LABEL = {
/**
* Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in
* Einrichtung oder aktiv. Terminplaner und Webshop (ticketbasiert): "ordered" nur Abschalten (Aktivieren ohne Zahlung wuerde eine spaetere Zahlung im Portal
* verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst "Ticket nachlegen"), "active" nur Abschalten.
* Einrichtung oder aktiv. Terminplaner, Webshop und Oeffnungszeiten live (alle mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren
* ohne Zahlung wuerde eine spaetere Zahlung im Portal verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst
* "Ticket nachlegen"), "active" nur Abschalten; sonst ("off", "disabled", ohne Zustand) Aktivieren. Fuer "disabled" gibt es kein
* Abschalten, auch wenn das Ersatz-Ticket beim Abschalten fehlschlug (das Portal merkt sich das nicht; die Antwort nennt es nur einmal).
* hindernis (Portal: feature.hindernis): beim Terminplaner bleibt die Abnahme sichtbar und das Portal sperrt sie mit Begruendung
* (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen').
*/
export function aktionen(state, key, { ticketMissing = false } = {}) {
if (key === 'termine' || key === 'shop') {
export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) {
if (key === 'termine' || key === 'shop' || key === 'zeiten') {
if (state === 'ordered' || state === 'active') return ['disable']
if (state === 'setup') return ticketMissing ? ['ticket', 'abnahme', 'disable'] : ['abnahme', 'disable']
if (state === 'setup') {
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
return [...(ticketMissing ? ['ticket'] : []), ...abnahme, 'disable']
}
return ['activate']
}
const list = []
@@ -258,8 +275,20 @@ export function featureBody({ projectId, action, note, textformDate, textformCha
/** K16 (Unterlagen an den Kunden) ging beim Aktivieren nicht raus: Antwort mit warnung: true (Portal, funktionenAdmin). */
export const K16_WARNUNG = 'Eingeschaltet, aber die E-Mail mit den Unterlagen (K16) ging nicht raus. Bitte die Leistungsbeschreibung und den AVV von Hand an den Kunden senden.'
/** Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. */
export const speicherStatus = (antwort) => (antwort?.warnung === true ? { ok: false, text: K16_WARNUNG } : { ok: true, text: 'Gespeichert.' })
/** Dieselbe Warnung fuer Funktionen ohne AVV und ohne Anhaenge (Oeffnungszeiten live): sie behauptet weder Unterlagen noch einen AVV. */
export const K16_WARNUNG_OHNE_UNTERLAGEN = 'Eingeschaltet, aber die E-Mail „bereit“ (K16) ging nicht raus. Bitte den Kunden von Hand informieren.'
/** Funktionen, deren K16 keine Unterlagen und keinen AVV mitschickt (Portal: features[key].avv null, keine Anhaenge). */
const K16_OHNE_UNTERLAGEN = ['zeiten']
/**
* Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. Der Wortlaut der
* Warnung folgt der Funktion (key): Terminplaner und Webshop nennen Leistungsbeschreibung und AVV, Oeffnungszeiten live keinen AVV.
*/
export const speicherStatus = (antwort, key) =>
antwort?.warnung === true
? { ok: false, text: K16_OHNE_UNTERLAGEN.includes(key) ? K16_WARNUNG_OHNE_UNTERLAGEN : K16_WARNUNG }
: { ok: true, text: 'Gespeichert.' }
// --- Webshop: Anfrage des Kunden und Stand des Shops (Portal: GET /shop/:customerId) ---
+8
View File
@@ -2,6 +2,7 @@ import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth'
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
/**
* Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer).
@@ -111,6 +112,13 @@ export const getShop = (customerId) => request(`/shop/${enc(customerId)}`)
export const entscheideShopAnfrage = (customerId, { projectId, entscheidung, begruendung }) =>
request(`/shop/${enc(customerId)}/anfrage`, { method: 'POST', body: entscheidungBody({ projectId, entscheidung, begruendung }) })
// --- 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) ---
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
+1
View File
@@ -11,6 +11,7 @@ const LABELS = {
termin_einstellungen: 'Terminplaner-Einstellungen geändert',
termin_aktion: 'Termin bearbeitet',
termin_manuell: 'Termin eingetragen',
zeiten_gespeichert: 'Öffnungszeiten gespeichert',
}
/** Deutsche Beschriftung einer Aktion; unbekannte Aktionen erscheinen roh. */
+189
View File
@@ -0,0 +1,189 @@
// Oeffnungszeiten live im Kunden-Reiter "Funktionen": Statustext je Projekt, Pruefung des Navigations-Selektors (Team-Einstellung) und
// Rumpf fuer PATCH /funktionen/:customerId/zeiten/team. Rein, unter Node testbar (keine Appwrite-Importe).
import { berlinYmd, fmtYmd } from './format'
const TEXT_EINBAU = 'Leisten live – Einbau läuft'
/** einbau 'manuell' oder leer: der Neubau ist nicht angestossen, die Skript-Zeile kommt nicht von selbst auf die Seite. */
const TEXT_VON_HAND = 'Einbau läuft (Team setzt die Skript-Zeile von Hand)'
const TEXT_WARTET = 'wartet auf die Zeiten des Kunden'
/** Tage zwischen dem erkannten Abo-Ende (aboEndeAt) und dem Abschalten (Portal: zeiten/aboAbgleich.js, FRIST_TAGE). */
const FRIST_TAGE = 30
/** Kalendertag 'JJJJ-MM-TT' plus n Tage ('JJJJ-MM-TT'); reine Kalenderrechnung ohne Uhrzeit, also ohne Zeitumstellung. */
function plusTage(ymd, tage) {
const [jahr, monat, tag] = ymd.split('-').map(Number)
return new Date(Date.UTC(jahr, monat - 1, tag + tage)).toISOString().slice(0, 10)
}
/**
* Hinweis auf das Ende des Wartungs-Abos: die Funktion wird am Berliner Tag von aboEndeAt plus 30 Tage abgeschaltet, ausser der
* Kunde behaelt sie. Mit unlesbarem Datum ohne Tag ("nach 30 Tagen"), nie mit einem erfundenen.
*/
function aboEndeText(aboEndeAt) {
const lesbar = typeof aboEndeAt === 'string' && !Number.isNaN(Date.parse(aboEndeAt))
const wann = lesbar ? `am ${fmtYmd(plusTage(berlinYmd(aboEndeAt), FRIST_TAGE))}` : `nach ${FRIST_TAGE} Tagen`
return `Abo beendet – Abschaltung ${wann}, falls der Kunde die Funktion nicht behält`
}
/**
* Statustext unter dem Abzeichen (Portal: project.features.zeiten): in der Einrichtung bei hindernis 'zeiten_fehlen' "wartet auf die
* Zeiten des Kunden" (geht vor), sonst "Leisten live – Einbau läuft" nur bei einbau 'angestossen', bei 'manuell' oder leer "Einbau
* läuft (Team setzt die Skript-Zeile von Hand)"; in Einrichtung und aktiv zusaetzlich der Abo-Ende-Hinweis mit Frist
* (feature.aboEndeAt), durch " · " getrennt. In allen anderen Zustaenden '': Grund und Loeschdatum einer Abschaltung nennt
* abgeschaltetText (funktionenView). Ohne Anrede, nie "24 Uhr".
*/
export function zeitenStatusText(feature) {
const state = feature?.state
if (state !== 'setup' && state !== 'active') return ''
const teile = []
if (state === 'setup') teile.push(feature.hindernis === 'zeiten_fehlen' ? TEXT_WARTET : feature.einbau === 'angestossen' ? TEXT_EINBAU : TEXT_VON_HAND)
if (feature.aboEndeAt) teile.push(aboEndeText(feature.aboEndeAt))
return teile.join(' · ')
}
// --- Team-Einstellungen (nur ueber WOMS aenderbar) ---
/** Hoechstlaenge des Navigations-Selektors (Portal: ZEITEN_MAX.navSelektor). */
export const NAV_SELEKTOR_MAX = 120
/** Erlaubt sind A-Z a-z 0-9, Leerzeichen und # . - _ > : [ ] = " ' (also nie < { } ;); Spiegel von NAV_RE in zeiten/pruefen.js. */
const NAV_RE = /^[A-Za-z0-9 #.\-_>:[\]="']+$/
/** Meldung wie im Portal (normalisiereTeam), damit Formular und Server dieselbe Regel gleich benennen. */
export const NAV_SELEKTOR_FEHLER = `Der Selektor darf höchstens ${NAV_SELEKTOR_MAX} Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " ' enthalten.`
/**
* Prueft den Navigations-Selektor (wohin die Hinweis-Leiste der Website sich haengt) wie normalisiereTeam im Portal: nach dem Trimmen
* hoechstens 120 Zeichen aus der erlaubten Menge; leer (auch nur Leerraum, null, undefined) heisst "kein Selektor" und ist gueltig; kein Text
* ist ungueltig. => Fehlermeldung oder null.
*/
export function navSelektorFehler(text) {
if (text === undefined || text === null) return null
const s = typeof text === 'string' ? text.trim() : null
if (s === null || s.length > NAV_SELEKTOR_MAX || (s !== '' && !NAV_RE.test(s))) return NAV_SELEKTOR_FEHLER
return null
}
/**
* Rumpf fuer PATCH /funktionen/:customerId/zeiten/team: { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }.
* Selektor getrimmt, leer => null; die Schalter sind immer echte Wahrheitswerte (nur true gilt als an). Die Version (teamVersion aus
* der Ansicht) und ein ungueltiger Selektor gehen unveraendert mit: das Portal prueft beides (409 ZEITEN_VERALTET, 400 mit fehler).
*/
export function teamBody({ projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }) {
const selektor = typeof navSelektor === 'string' ? navSelektor.trim() : navSelektor
return {
projectId,
teamVersion,
navSelektor: selektor === '' || selektor === undefined ? null : selektor,
kontaktleisteAus: kontaktleisteAus === 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-warnungen { margin: 0; padding-left: 32px; }
.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 > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
.funktionen-panel-abschnitt pre {
+21 -2
View File
@@ -64,17 +64,35 @@ test('terminplaner_karten_vorhanden', () => {
assert.ok(d.liveUrl.startsWith(base))
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
}
assert.equal(DOCUMENTS.length, 11, 'keine Analyse-Karten und keine weiteren')
// Oeffnungszeiten live: Karte 12, hinter den Terminplaner- und Webshop-Karten; der Schluessel hat 28 Zeichen (WOMS-Grenze 36, Ruling R35)
assert.equal(DOCUMENTS.length, 12, '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: 12,
})
assert.ok(d.key.length <= 36, 'Dokument-ID hoechstens 36 Zeichen')
assert.equal(DOCUMENTS.at(-1), d, 'am Ende von DOCUMENTS, nach den Terminplaner- und Webshop-Karten')
assert.equal(DOCUMENTS.findIndex((x) => x.key === 'widerrufsbelehrung-funktionen'), 8, 'Terminplaner-Karten unveraendert davor')
})
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: '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: '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: '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 },
])
})
@@ -92,5 +110,6 @@ test('dry_run_listet_karten', () => {
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-webshop:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-webshop:')))
assert.ok(zeilen.includes('Rechtstext leistungsbeschreibung-zeiten: Leistungsbeschreibung Öffnungszeiten live'))
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(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)
})
+83 -1
View File
@@ -28,6 +28,7 @@ const {
featureBody,
speicherStatus,
K16_WARNUNG,
K16_WARNUNG_OHNE_UNTERLAGEN,
warenartLabel,
artikelzahlLabel,
anfrageStatus,
@@ -41,7 +42,7 @@ const {
entscheidungStatus,
} = await loadModule('src/lib/funktionenView.js')
test('FUNKTIONEN: News, Terminplaner und Webshop mit den Bestätigungstexten', () => {
test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in dieser Reihenfolge) mit den Bestätigungstexten', () => {
assert.deepEqual(FUNKTIONEN, [
{
key: 'news',
@@ -71,9 +72,28 @@ test('FUNKTIONEN: News, Terminplaner und Webshop mit den Bestätigungstexten', (
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'zeiten',
label: 'Öffnungszeiten live',
bestaetigen: {
activate: 'Öffnungszeiten live ohne Zahlung aktivieren? Es startet der Einbau: Das Einbau-Ticket wird angelegt und die Seite, wenn möglich, neu gebaut. Freigeschaltet wird, sobald das Ticket geschlossen ist und der Kunde seine Zeiten eingetragen hat.',
disable: 'Öffnungszeiten live abschalten? Leisten und Live-Anzeige verschwinden von der Website, die feste Ersatz-Tabelle bleibt stehen. Die Angaben des Kunden bleiben gespeichert. Weicht der Wochenplan von der Ersatz-Tabelle ab, wird ein Ticket „Ersatz-Tabelle aktualisieren“ angelegt.',
abnahme: 'Öffnungszeiten live freischalten? Der Kunde bekommt die Mail „bereit“ und der Einbau gilt als abgenommen.',
ticket: 'Fehlendes Einbau-Ticket neu anlegen?',
},
},
])
})
test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und Abschalten bestätigt', () => {
assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'shop', 'zeiten'])
for (const f of FUNKTIONEN) {
assert.ok(f.bestaetigen.activate && f.bestaetigen.disable, f.key)
}
const zeiten = FUNKTIONEN.find((f) => f.key === 'zeiten').bestaetigen
for (const text of Object.values(zeiten)) assert.doesNotMatch(text, /24 Uhr/, 'nie "24 Uhr"')
})
test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Bestellung (149 €)', () => {
const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable
assert.match(text, /^Terminplaner abschalten\?/)
@@ -146,6 +166,40 @@ test('aktionen: Terminplaner mit fehlendem Ticket bietet in der Einrichtung zuer
}
})
test('aktionen: Öffnungszeiten live – aus nur Aktivieren, bestellt nur Abschalten, Einrichtung mit Abnahme, aktiv nur Abschalten, abgeschaltet nur Aktivieren', () => {
assert.deepEqual(aktionen('off', 'zeiten'), ['activate'])
assert.deepEqual(aktionen('ordered', 'zeiten'), ['disable'], 'wie beim Terminplaner: Aktivieren würde eine spätere Zahlung verschlucken')
assert.deepEqual(aktionen('setup', 'zeiten'), ['abnahme', 'disable'])
assert.deepEqual(aktionen('active', 'zeiten'), ['disable'])
assert.deepEqual(aktionen('disabled', 'zeiten'), ['activate'])
assert.deepEqual(aktionen(undefined, 'zeiten'), ['activate'])
})
test('aktionen: Öffnungszeiten live bietet die Abnahme nur ohne Hindernis an (wartet auf die Zeiten des Kunden: nur Abschalten)', () => {
assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: 'zeiten_fehlen' }), ['disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: null }), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: undefined }), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: true, hindernis: 'zeiten_fehlen' }), ['ticket', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'zeiten', { hindernis: 'zeiten_fehlen' }), aktionen(state, 'zeiten'), `${String(state)}: hindernis nur in setup`)
}
})
test('aktionen: Öffnungszeiten live mit fehlendem Ticket bietet in der Einrichtung zuerst "ticket" an (wie der Terminplaner)', () => {
assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: true }), ['ticket', 'abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: false }), ['abnahme', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'zeiten', { ticketMissing: true }), aktionen(state, 'zeiten'), `${String(state)}: ticketMissing nur in setup`)
}
})
test('aktionen: Terminplaner und News ignorieren hindernis (der Terminplaner zeigt die Abnahme weiter, das Portal sperrt)', () => {
for (const hindernis of ['kein_abo', 'keine_einstellung', 'abo_unbekannt', 'zeiten_fehlen', null]) {
assert.deepEqual(aktionen('setup', 'termine', { hindernis }), ['abnahme', 'disable'], String(hindernis))
assert.deepEqual(aktionen('setup', 'news', { hindernis }), ['activate', 'disable'], String(hindernis))
}
})
test('AKTION_LABEL: Beschriftung je Aktion', () => {
assert.deepEqual(AKTION_LABEL, {
activate: 'Aktivieren (ohne Zahlung)',
@@ -338,6 +392,17 @@ test('siteKeyOf: nur der Terminplaner zeigt seinen Site-Key, nur wenn das Portal
}
})
test('siteKeyOf liefert für zeiten den Schlüssel, für news weiter ""', () => {
assert.equal(siteKeyOf({ state: 'setup', siteKey: 'abcdefghijklmnop' }, 'zeiten'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'setup', siteKey: ' abcdefghijklmnop ' }, 'zeiten'), 'abcdefghijklmnop', 'getrimmt')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'news'), '')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, undefined), '')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'unbekannt'), '')
for (const feature of [{ state: 'off' }, { state: 'setup', siteKey: null }, { state: 'setup', siteKey: '' }, { state: 'setup', siteKey: 5 }, null, undefined]) {
assert.equal(siteKeyOf(feature, 'zeiten'), '', JSON.stringify(feature))
}
})
test('abgeschaltetText: Grund und Löschdatum im Zustand "disabled"', () => {
const text = (disabledReason, loeschungAm) => abgeschaltetText({ state: 'disabled', disabledReason, loeschungAm })
assert.equal(text('abo_ende', '2026-11-04'), 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026')
@@ -558,6 +623,23 @@ test('speicherStatus: warnung true (K16 nicht gesendet) wird zur Warnung, sonst
assert.match(K16_WARNUNG, /von Hand/)
assert.deepEqual(speicherStatus({ projects: [] }), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(speicherStatus(undefined), { ok: true, text: 'Gespeichert.' })
// Terminplaner und Webshop: der Wortlaut nennt Unterlagen und AVV (mit und ohne Schluessel gleich)
for (const key of [undefined, 'news', 'termine', 'shop']) {
assert.deepEqual(speicherStatus({ warnung: true }, key), { ok: false, text: K16_WARNUNG }, String(key))
}
assert.match(K16_WARNUNG, /AVV/)
})
test('speicherStatus: Öffnungszeiten live haben keinen AVV, die Warnung behauptet keinen', () => {
const antwort = speicherStatus({ projects: [], warnung: true }, 'zeiten')
assert.deepEqual(antwort, { ok: false, text: K16_WARNUNG_OHNE_UNTERLAGEN })
assert.match(antwort.text, /K16/)
assert.match(antwort.text, /von Hand/)
assert.doesNotMatch(antwort.text, /AVV|Auftragsverarbeitung|Unterlagen/)
assert.notEqual(antwort.text, K16_WARNUNG)
// ohne Warnung bleibt es bei "Gespeichert." (auch bei zeiten)
assert.deepEqual(speicherStatus({ projects: [] }, 'zeiten'), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(speicherStatus(undefined, 'zeiten'), { ok: true, text: 'Gespeichert.' })
})
test('aktionen: Webshop verhält sich wie der Terminplaner (ticketbasiert)', () => {
+3 -3
View File
@@ -5,15 +5,15 @@ import { loadModule } from './load.mjs'
// Protokoll der Kundenansicht: Aktionen des Portals (customerViewLog.action) als deutsche Beschriftung
const { viewLogLabel, formatViewLogTime } = await loadModule('src/lib/viewLog.js')
test('viewLogLabel: alle zehn Aktionen und Rohwert', () => {
test('viewLogLabel: alle elf Aktionen und Rohwert', () => {
assert.deepEqual(
[
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
'termin_einstellungen', 'termin_aktion', 'termin_manuell',
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'zeiten_gespeichert',
].map(viewLogLabel),
[
'Kundenansicht geöffnet', 'Kundenansicht beendet', 'Kundenansicht abgelaufen', 'News angelegt', 'News-Foto hochgeladen', 'News geändert', 'News gelöscht',
'Terminplaner-Einstellungen geändert', 'Termin bearbeitet', 'Termin eingetragen',
'Terminplaner-Einstellungen geändert', 'Termin bearbeitet', 'Termin eingetragen', 'Öffnungszeiten gespeichert',
]
)
assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas')
+350
View File
@@ -0,0 +1,350 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Ö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,
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 VON_HAND = 'Einbau läuft (Team setzt die Skript-Zeile von Hand)'
const WARTET = 'wartet auf die Zeiten des Kunden'
test('zeitenStatusText: Einrichtung ohne Hindernis, Einbau angestossen – Leisten sind live, der Einbau läuft', () => {
assert.equal(zeitenStatusText({ state: 'setup', hindernis: null, einbau: 'angestossen' }), EINBAU)
assert.equal(zeitenStatusText({ state: 'setup', einbau: 'angestossen' }), EINBAU, 'ohne Feld hindernis')
assert.equal(zeitenStatusText({ state: 'setup', hindernis: '', einbau: 'angestossen' }), EINBAU)
})
test('zeitenStatusText: Einrichtung mit einbau manuell oder leer – nie "Leisten live", das Team setzt die Zeile von Hand', () => {
for (const einbau of ['manuell', '', null, undefined, 'irgendwas_neues']) {
assert.equal(zeitenStatusText({ state: 'setup', hindernis: null, einbau }), VON_HAND, JSON.stringify(einbau))
}
assert.equal(zeitenStatusText({ state: 'setup' }), VON_HAND, 'ohne Feld einbau')
assert.doesNotMatch(VON_HAND, /Leisten live/)
})
test('zeitenStatusText: Einrichtung mit hindernis "zeiten_fehlen" – wartet auf die Zeiten des Kunden (geht jedem Einbau-Text vor)', () => {
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'zeiten_fehlen' }), WARTET)
for (const einbau of ['angestossen', 'manuell', '', null]) {
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'zeiten_fehlen', einbau }), WARTET, JSON.stringify(einbau))
}
})
test('zeitenStatusText: unbekanntes Hindernis zeigt nichts Erfundenes, sondern den Einbau-Text', () => {
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'irgendwas_neues', einbau: 'angestossen' }), EINBAU)
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'irgendwas_neues', einbau: 'manuell' }), VON_HAND)
})
test('zeitenStatusText: Abo-Ende nennt die Frist (Berliner Tag des Abo-Endes plus 30 Tage)', () => {
const abo = (state, aboEndeAt) => zeitenStatusText({ state, hindernis: null, aboEndeAt })
const hinweis = (datum) => `Abo beendet – Abschaltung am ${datum}, falls der Kunde die Funktion nicht behält`
assert.equal(abo('active', '2026-10-04T10:00:00.000Z'), hinweis('03.11.2026'))
// 22:30 UTC ist in Berlin schon der nächste Tag (Sommerzeit, +2 h): 05.10. -> 04.11.
assert.equal(abo('active', '2026-10-04T22:30:00.000Z'), hinweis('04.11.2026'))
// Monats- und Jahreswechsel, Zeitumstellung am 25.10. spielt bei Kalendertagen keine Rolle
assert.equal(abo('active', '2026-10-20T12:00:00.000Z'), hinweis('19.11.2026'))
assert.equal(abo('active', '2026-12-15T12:00:00.000Z'), hinweis('14.01.2027'))
// Appwrite liefert auch +00:00
assert.equal(abo('active', '2026-10-04T10:00:00.000+00:00'), hinweis('03.11.2026'))
})
test('zeitenStatusText: Abo-Ende in der Einrichtung steht zusätzlich zum Einbau- bzw. Warte-Text', () => {
const aboEndeAt = '2026-10-04T10:00:00.000Z'
const frist = 'Abo beendet – Abschaltung am 03.11.2026, falls der Kunde die Funktion nicht behält'
assert.equal(zeitenStatusText({ state: 'setup', hindernis: null, einbau: 'angestossen', aboEndeAt }), `${EINBAU} · ${frist}`)
assert.equal(zeitenStatusText({ state: 'setup', hindernis: null, einbau: 'manuell', aboEndeAt }), `${VON_HAND} · ${frist}`)
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'zeiten_fehlen', aboEndeAt }), `${WARTET} · ${frist}`)
})
test('zeitenStatusText: Abo-Ende mit unlesbarem Datum nennt 30 Tage statt eines erfundenen Tages', () => {
const text = zeitenStatusText({ state: 'active', aboEndeAt: 'kein-datum' })
assert.equal(text, 'Abo beendet – Abschaltung nach 30 Tagen, falls der Kunde die Funktion nicht behält')
})
test('zeitenStatusText: aktiv ohne Abo-Ende, aus, bestellt, abgeschaltet, ohne Ansicht – leer', () => {
assert.equal(zeitenStatusText({ state: 'active', hindernis: null, aboEndeAt: null }), '')
assert.equal(zeitenStatusText({ state: 'active' }), '')
assert.equal(zeitenStatusText({ state: 'off' }), '')
assert.equal(zeitenStatusText({ state: 'ordered' }), '')
// abgeschaltet: Grund und Löschdatum zeigt abgeschaltetText, nichts doppelt
assert.equal(zeitenStatusText({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-11-04', aboEndeAt: '2026-10-04T10:00:00.000Z' }), '')
assert.equal(zeitenStatusText({ state: 'off', aboEndeAt: '2026-10-04T10:00:00.000Z' }), '')
assert.equal(zeitenStatusText(null), '')
assert.equal(zeitenStatusText(undefined), '')
})
test('zeitenStatusText: kein Text nennt "24 Uhr"', () => {
for (const feature of [
{ state: 'setup' },
{ state: 'setup', einbau: 'angestossen' },
{ state: 'setup', einbau: 'manuell' },
{ state: 'setup', hindernis: 'zeiten_fehlen' },
{ state: 'active', aboEndeAt: '2026-10-04T10:00:00.000Z' },
{ state: 'setup', hindernis: 'zeiten_fehlen', aboEndeAt: '2026-10-04T22:30:00.000Z' },
]) {
assert.doesNotMatch(zeitenStatusText(feature), /24 Uhr/)
}
})
test('navSelektorFehler: leer ist erlaubt (kein Selektor)', () => {
for (const wert of [undefined, null, '', ' ', '\n\t ']) {
assert.equal(navSelektorFehler(wert), null, JSON.stringify(wert))
}
})
test('navSelektorFehler: übliche Selektoren sind gültig', () => {
for (const wert of [
'header',
'#nav',
'.site-header',
'.site-header > nav',
'header#main-nav.fixed',
'nav[role="navigation"]',
"a[href='x']",
'ul.menu:first-child',
'div_wrap .a-b',
'ABC xyz 019',
' header ',
]) {
assert.equal(navSelektorFehler(wert), null, wert)
}
})
test('navSelektorFehler: erlaubt sind nur A–Z a–z 0–9, Leerzeichen und # . - _ > : [ ] = " \'', () => {
const zeichen = 'AZaz09 #.-_>:[]="\''
assert.equal(navSelektorFehler(zeichen), null)
assert.equal(navSelektorFehler('a'.repeat(NAV_SELEKTOR_MAX)), null)
})
test('navSelektorFehler: < { } ; und alle übrigen Zeichen werden abgelehnt', () => {
for (const zeichen of ['<', '{', '}', ';', '(', ')', ',', '*', '+', '~', '/', '\\', '@', '!', '|', '$', '%', '^', '&', '?', '`', 'ä', 'ß', '€', '\n', '\t']) {
assert.equal(navSelektorFehler(`a${zeichen}b`), NAV_SELEKTOR_FEHLER, JSON.stringify(zeichen))
}
assert.equal(navSelektorFehler('header { display: none }'), NAV_SELEKTOR_FEHLER)
assert.equal(navSelektorFehler('</style><script>'), NAV_SELEKTOR_FEHLER)
assert.equal(navSelektorFehler('a\nb'), NAV_SELEKTOR_FEHLER, 'Zeilenumbruch mitten im Text')
})
test('navSelektorFehler: höchstens 120 Zeichen, gezählt nach dem Trimmen', () => {
assert.equal(NAV_SELEKTOR_MAX, 120)
assert.equal(navSelektorFehler('a'.repeat(120)), null)
assert.equal(navSelektorFehler(` ${'a'.repeat(120)} `), null, 'Leerraum außen zählt nicht')
assert.equal(navSelektorFehler('a'.repeat(121)), NAV_SELEKTOR_FEHLER)
assert.equal(navSelektorFehler(`${'a'.repeat(60)} ${'b'.repeat(60)}`), NAV_SELEKTOR_FEHLER, '121 mit Leerzeichen innen')
})
test('navSelektorFehler: kein Text wird abgelehnt wie auf dem Server (Zahl, Objekt, Wahrheitswert)', () => {
for (const wert of [5, true, {}, []]) {
assert.equal(navSelektorFehler(wert), NAV_SELEKTOR_FEHLER, JSON.stringify(wert))
}
})
test('NAV_SELEKTOR_FEHLER: wörtlich wie die Meldung des Portals', () => {
assert.equal(NAV_SELEKTOR_FEHLER, 'Der Selektor darf höchstens 120 Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " \' enthalten.')
})
test('teamBody: genau die fünf Felder der Team-Route, Selektor getrimmt, leer wird null', () => {
assert.deepEqual(
teamBody({ projectId: 'p1', teamVersion: 3, navSelektor: ' header > nav ', kontaktleisteAus: true, hinweisleisteAus: false }),
{ projectId: 'p1', teamVersion: 3, navSelektor: 'header > nav', kontaktleisteAus: true, hinweisleisteAus: false }
)
for (const leer of ['', ' ', null, undefined]) {
assert.equal(teamBody({ projectId: 'p1', teamVersion: 0, navSelektor: leer, kontaktleisteAus: false, hinweisleisteAus: false }).navSelektor, null, JSON.stringify(leer))
}
})
test('teamBody: Schalter sind immer echte Wahrheitswerte, nur true gilt als an', () => {
const body = (kontaktleisteAus, hinweisleisteAus) => teamBody({ projectId: 'p1', teamVersion: 1, navSelektor: '', kontaktleisteAus, hinweisleisteAus })
assert.deepEqual(body(true, true), { projectId: 'p1', teamVersion: 1, navSelektor: null, kontaktleisteAus: true, hinweisleisteAus: true })
assert.deepEqual(body(undefined, undefined), { projectId: 'p1', teamVersion: 1, navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
assert.deepEqual(body('true', 1), { projectId: 'p1', teamVersion: 1, navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
})
test('teamBody: die Version wird unverändert durchgereicht (das Portal prüft sie), auch 0', () => {
assert.equal(teamBody({ projectId: 'p1', teamVersion: 0, navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: false }).teamVersion, 0)
assert.equal(teamBody({ projectId: 'p1', teamVersion: 41, navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: false }).teamVersion, 41)
})
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}')
})
// --- 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/)
})