Files
Webklar-Kundenbereich/public/neu/ansicht-funktionen.js
T
ClaudeandClaude Sonnet 5.5 6bfcaa892c Kundenansicht neu (Meine): Kurzinfo nur auf Anfrage (GET /api/funktionen?kurzinfo=1)
Der Server berechnet kurzinfo (Terminplaner, Webshop, News, Buchungskalender, Öffnungszeiten) nur noch,
wenn die neue Ansicht ?kurzinfo=1 sendet. Das tut allein die Seite "Meine"; Etagen, Einschalt-Ablauf und
Module fragen ohne den Parameter, dann fehlt das Feld und es gibt keine Abfrage auf Termine, Bestellungen,
Beiträge und Anfragen. Mit dem Parameter bleibt die Antwort wie bisher (Text oder null je Funktion).
Nur der Wert "1" gilt; doppelte oder als Liste gesendete Parameter heißen: ohne. Alte Ansicht unverändert.

Tests: kundenansichtKurzinfo (neuer Fall ohne/mit Parameter, Abfragen und Feld), knFunktionenAnsicht
(die Seite lädt genau einmal ?kurzinfo=1, auch nach "Nochmal versuchen").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:46:48 +00:00

345 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Kundenansicht neu (P3): Bereich „Funktionen“ mit den Reitern Meine und Entdecken.
Der erste Teil ist rein (Modell aus der Antwort von GET /api/funktionen im Abo-Modell, V5); die Ansicht darunter zeichnet es.
Keine Preise: Der Katalog trägt keinen. Fremdtext (Funktions- und Websitenamen) wird nur als Text gesetzt. */
import { api as apiStandard } from './api.js'
import { h, leeren } from './dom.js'
import { baueReiter } from './reiter.js'
import { geheZu as geheZuStandard, oeffneOverlay as oeffneOverlayStandard, beendeOverlay as beendeOverlayStandard } from './router.js'
import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js'
import { ETAGE_FUER, TEXTE, anfrageStatusVon, einschaltWeg, navigiere, nimmMeldung, renderEinschalten } from './funktion-einschalten.js'
const ANFRAGE_SICHTBAR = ['offen', 'angenommen', 'abgelehnt'] // zurückgezogen zählt nicht
/** Zeitpunkt oder 'JJJJ-MM-TT' → 'TT.MM.JJJJ' (Europe/Berlin); unlesbar ergibt ''. Ein Tagestext wird als Tag um 12:00 UTC gelesen. */
export function datumDe(wert) {
if (typeof wert !== 'string' || wert === '') return ''
const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(wert) ? `${wert}T12:00:00Z` : wert)
if (Number.isNaN(ms)) return ''
return new Date(ms).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
}
const verfuegbare = (d) => (Array.isArray(d?.katalog) ? d.katalog.filter((e) => e && typeof e === 'object' && e.verfuegbar === true && typeof e.key === 'string') : [])
const websites = (d) => (Array.isArray(d?.projects) ? d.projects.filter((p) => p && typeof p === 'object' && typeof p.projectId === 'string') : [])
const zustandVon = (projekt, key) => (key === 'analyse' ? projekt.analyse?.state : projekt.features?.[key]?.state)
/** Hat diese Website die Funktion schon (Zeile in Meine)? Eine Zeile ist Zustand, Entwurf des Fragebogens oder sichtbare Anfrage. */
function istInMeine(eintrag, projekt) {
if (eintrag.key === 'analyse') return ['active', 'disabled'].includes(projekt.analyse?.state)
const f = projekt.features?.[eintrag.key]
if (!f || typeof f !== 'object') return false
if (f.state !== 'off') return typeof f.state === 'string'
if (eintrag.ablauf === 'fragebogen') return Boolean(f.siteKey)
if (eintrag.ablauf === 'anfrage') return ANFRAGE_SICHTBAR.includes(anfrageStatusVon(projekt, eintrag.key))
return false
}
/** „Dein Abo ist beendet.“ mit dem Löschtag, wenn einer da ist. */
function aboEndeText(f) {
const tag = datumDe(f.loeschungAm)
return tag ? `Dein Abo ist beendet. Deine Daten bleiben bis ${tag} gespeichert.` : 'Dein Abo ist beendet.'
}
const KURZINFO_MAX = 80 // wie der Server (kurzinfo.js): länger ist kein Detail mehr, sondern ein Fehler
/** Kurzinfo einer aktiven Funktion aus der Antwort des Servers: nur ein nicht leerer, kurzer Text; sonst '' (die Zeile zeigt dann „Aktiv“). */
function kurzinfoVon(quelle) {
const text = typeof quelle?.kurzinfo === 'string' ? quelle.kurzinfo.trim() : ''
return text !== '' && text.length <= KURZINFO_MAX ? text : ''
}
/**
* Pille und Detailzeile einer Funktion an einer Website (nur für Websites, bei denen istInMeine gilt). Läuft die Funktion, gibt es keine
* Pille: die Detailzeile ist die Kurzinfo des Servers („3 Termine heute · nächster 10:30“) oder „Aktiv“. Alles andere trägt eine Pille.
*/
function statusVon(eintrag, projekt) {
if (eintrag.key === 'analyse') {
const a = projekt.analyse
if (a.state === 'active') return { pille: null, status: kurzinfoVon(a) || 'Aktiv' }
return { pille: { text: 'Gesperrt', ton: 'grau' }, status: a.disabledReason === 'abo_ende' ? aboEndeText(a) : 'Schreib uns im Chat' }
}
const f = projekt.features[eintrag.key]
switch (f.state) {
case 'setup':
return { pille: { text: 'In Einrichtung', ton: 'blau' }, status: 'Wir richten sie gerade ein' }
case 'ordered':
return { pille: { text: 'Zahlung offen', ton: 'orange' }, status: 'Wir warten auf deine Zahlung' }
case 'active':
return { pille: null, status: kurzinfoVon(f) || 'Aktiv' }
case 'disabled':
return { pille: { text: 'Abgeschaltet', ton: 'grau' }, status: f.disabledReason === 'abo_ende' ? aboEndeText(f) : 'Im Chat wieder anfragen' }
default: {
if (eintrag.ablauf === 'fragebogen') return { pille: { text: 'Entwurf', ton: 'grau' }, status: 'Fragebogen fortsetzen' }
const anfrage = anfrageStatusVon(projekt, eintrag.key)
if (anfrage === 'angenommen') return { pille: { text: 'Angenommen', ton: 'blau' }, status: 'Jetzt einschalten' }
if (anfrage === 'abgelehnt') return { pille: { text: 'Abgelehnt', ton: 'rot' }, status: 'Anfrage abgelehnt · Im Chat nachfragen' }
return { pille: { text: 'Angefragt', ton: 'violett' }, status: 'Wir prüfen deine Anfrage' }
}
}
}
/**
* Die Zeilen von „Meine“: je Funktion und Website eine, in Katalogreihenfolge, dann Websitereihenfolge. Funktionen mit
* verfuegbar !== true fehlen (C9). pille ist null, solange die Funktion läuft; status ist die Detailzeile (Kurzinfo oder Zustandstext),
* zeile2 ist bei mehreren Websites „Websitename · status“, sonst der Status allein.
* @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }|null, status: string, zeile2: string }>}
*/
export function meineZeilen(daten) {
const seiten = websites(daten)
const mehrere = seiten.length > 1
const zeilen = []
for (const eintrag of verfuegbare(daten)) {
for (const projekt of seiten) {
if (!istInMeine(eintrag, projekt)) continue
const { pille, status } = statusVon(eintrag, projekt)
const projektName = String(projekt.projectName ?? '')
zeilen.push({
key: eintrag.key,
name: String(eintrag.name ?? ''),
projectId: projekt.projectId,
projektName,
zustand: String(zustandVon(projekt, eintrag.key) ?? ''),
anfrageStatus: eintrag.key === 'analyse' ? '' : anfrageStatusVon(projekt, eintrag.key),
pille,
status,
zeile2: mehrere && projektName !== '' ? `${projektName} · ${status}` : status,
})
}
}
return zeilen
}
/**
* Die Einträge von „Entdecken“ in Katalogreihenfolge: solange eine Website die Funktion nicht hat oder es keine Website gibt.
* kandidaten sind die Websites ohne die Funktion (Analyse: nur mit Zustand off; unknown und comingSoon sind kein Kandidat).
* @returns {Array<{ eintrag: object, kandidaten: Array<{ projectId: string, projektName: string }> }>}
*/
export function entdeckenEintraege(daten) {
const seiten = websites(daten)
const ergebnis = []
for (const eintrag of verfuegbare(daten)) {
const ohne = seiten.filter((p) => !istInMeine(eintrag, p))
if (seiten.length > 0 && ohne.length === 0) continue
const kandidaten = (eintrag.key === 'analyse' ? ohne.filter((p) => p.analyse?.state === 'off') : ohne)
.map((p) => ({ projectId: p.projectId, projektName: String(p.projectName ?? '') }))
ergebnis.push({ eintrag, kandidaten })
}
return ergebnis
}
/** Tipp auf eine Zeile in Meine: eine angenommene Anfrage geht zur Bestätigung, alles andere in die Etage der Funktion. */
export function zeilenWeg(zeile) {
if (zeile.zustand === 'off' && zeile.anfrageStatus === 'angenommen') return { art: 'seite' }
return { art: 'etage', etage: ETAGE_FUER[zeile.key] }
}
/* ------------------------------------------------------------------------------------------------ Ansicht --- */
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs: eine ältere Antwort zeichnet nichts mehr
let reiterGewaehlt = false // Tipp auf „Meine“: der nächste Aufruf zeigt den Leerzustand statt zu Entdecken zu springen
let vorgabe = null // nach einer eigenen Navigation (Reiter, Sprung zu Entdecken) nimmt der nächste Aufruf die geladenen Antworten
const wechselErledigt = new Set() // 'key:projectId' der Funktionen, in die die Rückkehr vom Wechsel schon geführt hat (je Seitenaufruf einmal)
/** Setzt die Merker des Moduls zurück (nur für Tests). */
export function vergissMerker() {
reiterGewaehlt = false
vorgabe = null
wechselErledigt.clear()
}
const BILD = /^\/neu\/bilder\/[a-z0-9-]+\.webp$/
/** Nur diese Seite (Meine, Detailzeile) bittet den Server um die Kurzinfo; Etagen, Einschalt-Ablauf und Module laden GET /api/funktionen ohne den Parameter. */
const FUNKTIONEN_PFAD = '/api/funktionen?kurzinfo=1'
/** Stück für die Pille einer Zeile. */
const pille = (p) => h('span', { class: `kn-fn-pille kn-fn-pille--${p.ton}` }, p.text)
export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {}) {
const A = {
api: abh.api ?? apiStandard,
geheZu: abh.geheZu ?? geheZuStandard,
oeffneOverlay: abh.oeffneOverlay ?? oeffneOverlayStandard,
beendeOverlay: abh.beendeOverlay ?? beendeOverlayStandard,
speicher: abh.speicher,
jetzt: abh.jetzt ?? (() => new Date()),
}
const nr = (laeufe.get(wurzel) ?? 0) + 1
laeufe.set(wurzel, nr)
const aktuell = () => laeufe.get(wurzel) === nr
const nochmal = () => zeigeFunktionen(wurzel, { route, me, nav }, abh)
const reiterWahl = route?.reiter === 'entdecken' ? 'entdecken' : 'meine'
const ausdruecklich = reiterGewaehlt
reiterGewaehlt = false
const mitgebracht = vorgabe && vorgabe.wurzel === wurzel && vorgabe.bis > Date.now() ? vorgabe : null
vorgabe = null
let daten
let offen
const frisch = !mitgebracht
if (mitgebracht) {
({ daten, offen } = mitgebracht)
} else {
leeren(wurzel).append(h('p', { class: 'kn-leer' }, 'Wird geladen …'))
try {
[daten, offen] = await Promise.all([A.api(FUNKTIONEN_PFAD), ladeOffeneRechnungen(A.api)])
} catch {
if (!aktuell()) return
leeren(wurzel).append(
h('p', { class: 'kn-fehler', role: 'alert' }, 'Die Funktionen konnten gerade nicht geladen werden.'),
h('button', { type: 'button', class: 'kn-fn-knopf kn-fn-knopf--zweit', 'data-aktion': 'nochmal', on: { click: () => nochmal() } }, 'Nochmal versuchen'))
return
}
if (!aktuell()) return
}
if (daten?.modell !== 'abo') {
leeren(wurzel).append(h('p', { class: 'kn-fehler', role: 'alert' }, 'Die Funktionen sind gerade nicht verfügbar. Lade die Seite neu.'))
return
}
const kundenart = me?.customer?.kundenartWirksam ?? daten.kundenart
const viewAs = me?.viewAs === true
const zeilen = meineZeilen(daten)
const projektVon = (projectId) => daten.projects.find((p) => p?.projectId === projectId)
const weiter = (weg, ziel) => navigiere(weg, ziel, { geheZu: A.geheZu, speicher: A.speicher })
let gezeigteMeldung = ''
/** Die Bestätigungsseite für diese Funktion und Website als Overlay: die Zurück-Geste und „Abbrechen“ bringen die Liste ohne neuen Abruf zurück. */
function oeffneSeite(key, projectId) {
A.oeffneOverlay({ schliesse: () => { if (aktuell()) zeichneListe() } })
renderEinschalten(wurzel, {
key, projectId, daten, kundenartWirksam: kundenart, viewAs, jetzt: A.jetzt, api: A.api, geheZu: A.geheZu, speicher: A.speicher,
bei: { abbrechen: () => A.beendeOverlay() },
})
}
/** „Einschalten“ für eine Website: die Vorprüfung wählt den Ort; eine Meldung steht im Eintrag. */
function einschaltTipp(eintrag, kandidat, meldungEl) {
const weg = einschaltWeg({ eintrag, projekt: projektVon(kandidat.projectId), abo: daten.abo, kundenart })
if (weg.art === 'meldung') { meldungEl.textContent = weg.text; return }
meldungEl.textContent = ''
if (weg.art === 'seite') oeffneSeite(eintrag.key, kandidat.projectId)
else weiter(weg, { key: eintrag.key, projectId: kandidat.projectId })
}
/**
* Rückkehr vom Wechsel ins Wartungs-Abo (V11): hat der Kunde den Wechsel für eine Funktion begonnen und hat die Website sie noch nicht,
* öffnet die Ansicht ihren Ablauf, je Seitenaufruf einmal. Jeder Fehler und jede ungültige Angabe wird ignoriert.
* @returns {Promise<boolean>} true, wenn ein Ablauf geöffnet wurde
*/
async function wechselRueckkehr() {
if (daten.abo?.produkt !== 'wartung' || daten.abo.laeuft !== true) return false
let abo
try { abo = await A.api('/api/abo') } catch { return false }
if (!aktuell()) return false
const f = abo?.subscription?.wechselFunktion
if (!f || typeof f.key !== 'string' || typeof f.projectId !== 'string') return false
const merker = `${f.key}:${f.projectId}`
const eintrag = verfuegbare(daten).find((e) => e.key === f.key)
const projekt = projektVon(f.projectId)
if (!eintrag || !projekt || wechselErledigt.has(merker) || istInMeine(eintrag, projekt)) return false
const weg = einschaltWeg({ eintrag, projekt, abo: daten.abo, kundenart })
if (weg.art !== 'seite' && weg.art !== 'etage') return false
wechselErledigt.add(merker)
if (weg.art === 'seite') oeffneSeite(f.key, f.projectId)
else weiter(weg, { key: f.key, projectId: f.projectId })
return true
}
if (frisch && await wechselRueckkehr()) return
if (!aktuell()) return
if (reiterWahl === 'meine' && zeilen.length === 0 && !ausdruecklich) {
vorgabe = { wurzel, daten, offen, bis: Date.now() + 5000 }
A.geheZu('#entdecken', { ersetzen: true })
return
}
function meineListe() {
if (zeilen.length === 0) {
return [
h('p', { class: 'kn-leer' }, 'Du hast noch keine Funktion eingeschaltet.'),
h('button', { type: 'button', class: 'kn-fn-knopf', 'data-aktion': 'entdecken', on: { click: () => wechsleReiter('entdecken') } }, 'Entdecken'),
]
}
return h('ul', { class: 'kn-fn-liste' }, zeilen.map((z) => h('li', { class: 'kn-fn-zeile', 'data-fn': z.key, 'data-projekt': z.projectId },
h('button', {
type: 'button',
class: 'kn-fn-zeile-knopf',
on: {
click: () => {
const weg = zeilenWeg(z)
if (weg.art === 'seite') oeffneSeite(z.key, z.projectId)
else weiter(weg, { key: z.key, projectId: z.projectId })
},
},
},
h('span', { class: 'kn-fn-text' }, h('span', { class: 'kn-fn-name' }, z.name), h('span', { class: 'kn-fn-zeile2' }, z.zeile2)),
h('span', { class: 'kn-fn-rechts' }, z.pille ? pille(z.pille) : null, h('span', { class: 'kn-fn-pfeil', 'aria-hidden': 'true' }, '›'))))))
}
function eintragKarte({ eintrag, kandidaten }) {
const nurBetriebHinweis = eintrag.nurBetrieb === true && kundenart !== 'unternehmen'
const meldungEl = h('p', { class: 'kn-fn-meldung', role: 'alert' })
let wahl = null
const knopf = h('button', {
type: 'button',
class: 'kn-fn-knopf',
'data-aktion': 'einschalten',
on: {
click: () => {
if (kandidaten.length === 1) { einschaltTipp(eintrag, kandidaten[0], meldungEl); return }
if (wahl) { wahl.remove(); wahl = null; return }
wahl = h('div', { class: 'kn-fn-wahl', role: 'group', 'aria-label': 'Für welche Website?' },
kandidaten.map((k) => h('button', { type: 'button', class: 'kn-fn-knopf kn-fn-knopf--zweit', on: { click: () => einschaltTipp(eintrag, k, meldungEl) } }, k.projektName)))
knopf.parentNode.insertBefore(wahl, meldungEl)
},
},
}, 'Einschalten')
if (viewAs) knopf.disabled = true // Mitarbeiter-Kundenansicht: gesperrt statt erst nach dem Tipp ein 403-Text
const bild = typeof eintrag.bild === 'string' && BILD.test(eintrag.bild)
? h('img', { class: 'kn-fn-bild', src: eintrag.bild, alt: `Beispiel: ${eintrag.name}`, width: 960, height: 600, loading: 'lazy', on: { error: (ev) => ev.target.remove() } })
: null
return h('section', { class: 'kn-fn-eintrag', 'data-fn': eintrag.key },
bild,
h('div', { class: 'kn-fn-eintrag-text' },
h('h3', null, String(eintrag.name ?? '')),
h('p', { class: 'kn-fn-beschreibung' }, String(eintrag.beschreibung ?? '')),
h('p', { class: 'kn-fn-enthalten' }, eintrag.enthaltenIn === 'hosting' ? 'Im Hosting-Abo enthalten' : 'Im Wartungs-Abo enthalten'),
nurBetriebHinweis ? h('p', { class: 'kn-fn-hinweis' }, TEXTE.nurBetrieb(String(eintrag.name ?? ''))) : (kandidaten.length ? knopf : null),
!nurBetriebHinweis && kandidaten.length && viewAs ? h('p', { class: 'kn-fn-info' }, TEXTE.kundenansichtKnopf) : null,
meldungEl))
}
function entdeckenListe() {
const eintraege = entdeckenEintraege(daten)
if (eintraege.length === 0) return h('p', { class: 'kn-leer' }, 'Du hast schon alles eingeschaltet, was es gibt.')
const ohneWebsite = daten.projects.length === 0
return [
ohneWebsite ? h('p', { class: 'kn-fn-info' }, 'Sobald deine Website angelegt ist, kannst du Funktionen einschalten.') : null,
...eintraege.map(eintragKarte),
]
}
/** Reiterwechsel: Meine oder Entdecken; die geladenen Antworten bleiben für den folgenden Aufruf des Routers. */
function wechsleReiter(key) {
if (key === 'meine') reiterGewaehlt = true
vorgabe = { wurzel, daten, offen, bis: Date.now() + 5000 }
A.geheZu(key === 'meine' ? '#funktionen' : '#entdecken', { ersetzen: true })
}
/** Reiter, Rechnungszeile und Liste aus den geladenen Antworten zeichnen (auch nach dem Schließen der Bestätigung, ohne Abruf). */
function zeichneListe() {
const reiter = baueReiter({ eintraege: [{ key: 'meine', label: 'Meine' }, { key: 'entdecken', label: 'Entdecken' }], aktiv: reiterWahl, art: 'unterstrich', ariaLabel: 'Funktionen', bei: wechsleReiter })
leeren(wurzel).append(
...[
gezeigteMeldung ? h('p', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, gezeigteMeldung) : null,
rechnungOffenZeile(offen),
reiter,
h('div', { class: 'kn-reiter-inhalt' }, reiterWahl === 'meine' ? meineListe() : entdeckenListe()),
].filter(Boolean))
}
gezeigteMeldung = nimmMeldung() // genau einmal; nach dem Schließen der Bestätigung bleibt sie stehen
zeichneListe()
}