Compare commits
6
Commits
7a3bc2887e
...
35e2761e68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e2761e68 | ||
|
|
f67192db52 | ||
|
|
8319084930 | ||
|
|
919d96f7a0 | ||
|
|
3d67aa88cf | ||
|
|
994347408d |
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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 } */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.' }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>ä</table>', clipboard), 'kopiert')
|
||||
assert.deepEqual(geschrieben, ['<table>ä</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/)
|
||||
})
|
||||
Reference in new issue
Block a user