From 36890c6734b94e86355cf146a1c345210bf4e37c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 18:11:36 +0000 Subject: [PATCH] =?UTF-8?q?Funktionen=20neu:=20Best=C3=A4tigung=20als=20Ov?= =?UTF-8?q?erlay=20der=20Zur=C3=BCck-Geste,=20Erfolgsmeldung,=20R=C3=BCckk?= =?UTF-8?q?ehr=20nach=20dem=20Wechsel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansicht-funktionen.js | 73 +++++++++++++++++++------ test/knFunktionenAnsicht.test.js | 94 ++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 17 deletions(-) diff --git a/public/neu/ansicht-funktionen.js b/public/neu/ansicht-funktionen.js index 8d75972..71beb6e 100644 --- a/public/neu/ansicht-funktionen.js +++ b/public/neu/ansicht-funktionen.js @@ -127,11 +127,13 @@ export function zeilenWeg(zeile) { 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$/ @@ -160,6 +162,7 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {}) let daten let offen + const frisch = !mitgebracht if (mitgebracht) { ({ daten, offen } = mitgebracht) } else { @@ -183,18 +186,17 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {}) const kundenart = me?.customer?.kundenartWirksam ?? daten.kundenart const viewAs = me?.viewAs === true const zeilen = meineZeilen(daten) - if (reiterWahl === 'meine' && zeilen.length === 0 && !ausdruecklich) { - vorgabe = { wurzel, daten, offen, bis: Date.now() + 5000 } - A.geheZu('#entdecken', { ersetzen: true }) - return - } - 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. */ + /** 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) { - renderEinschalten(wurzel, { key, projectId, daten, kundenartWirksam: kundenart, viewAs, jetzt: A.jetzt, api: A.api, geheZu: A.geheZu, speicher: A.speicher }) + 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. */ @@ -206,6 +208,38 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {}) 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} 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 [ @@ -277,13 +311,18 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {}) A.geheZu(key === 'meine' ? '#funktionen' : '#entdecken', { ersetzen: true }) } - const meldung = nimmMeldung() - const reiter = baueReiter({ eintraege: [{ key: 'meine', label: 'Meine' }, { key: 'entdecken', label: 'Entdecken' }], aktiv: reiterWahl, art: 'unterstrich', ariaLabel: 'Funktionen', bei: wechsleReiter }) - leeren(wurzel).append( - ...[ - meldung ? h('p', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, meldung) : null, - rechnungOffenZeile(offen), - reiter, - h('div', { class: 'kn-reiter-inhalt' }, reiterWahl === 'meine' ? meineListe() : entdeckenListe()), - ].filter(Boolean)) + /** 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() } diff --git a/test/knFunktionenAnsicht.test.js b/test/knFunktionenAnsicht.test.js index 73cf764..9ab49ff 100644 --- a/test/knFunktionenAnsicht.test.js +++ b/test/knFunktionenAnsicht.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { installFakeDom } from './support/fakeDom.js' import { zeigeFunktionen, vergissMerker } from '../public/neu/ansicht-funktionen.js' +import { merkeMeldung } from '../public/neu/funktion-einschalten.js' import { daten, projekt, feature } from './support/knFunktionenDaten.js' const JETZT = () => new Date('2026-10-20T10:00:00Z') @@ -198,3 +199,96 @@ test('Meine mit zwei Websites zeigt den Websitenamen in der zweiten Zeile, mit e await zwei.zeige(MEINE) assert.match(fx.text(fx.finde(zwei.wurzel, 'li[data-fn]')), /News-Pinnwand.*Bäckerei.*Aktiv/) })) + +/* ---------------------------------------------------------------- Teil 2: Overlay, Erfolgsmeldung, Wechsel --- */ +test('Bestätigung als Overlay: Zurück-Geste bringt die Liste ohne neuen Abruf zurück, Abbrechen ebenso', mit(async (fx) => { + const u = umgebung(fx) + await u.zeige(ENTDECKEN) + const geladen = () => u.aufrufe.filter(([m, p]) => m === 'GET' && p === '/api/funktionen').length + await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]')) + assert.match(fx.text(u.wurzel), /News-Pinnwand einschalten/) + assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 0) + u.overlay() // Zurück-Geste des Browsers (der Router ruft schliesse) + assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6) + await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]')) + await fx.klick(fx.finde(u.wurzel, '[data-aktion="abbrechen"]')) + assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6) + assert.deepEqual([geladen(), u.gegangen], [1, []]) +})) + +test('Overlay: eine ältere Seite zeichnet nach einem neuen Aufruf nichts mehr; die Rechnungszeile und eine gezeigte Meldung bleiben', mit(async (fx) => { + const u = umgebung(fx) + u.rechnungen = [{ id: 'r1', number: 'RE-1', balance: 20, portalUrl: null }] + await u.zeige(ENTDECKEN) + await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]')) + const schliesse = u.overlay + await u.zeige(ENTDECKEN) // ein neuer Aufruf (z. B. Router) ersetzt die Anzeige + const vorher = fx.text(u.wurzel) + schliesse() // das alte Overlay meldet sich spät + assert.equal(fx.text(u.wurzel), vorher) + const v = umgebung(fx) + v.rechnungen = [{ id: 'r1', number: 'RE-1', balance: 20, portalUrl: null }] + await v.zeige(ENTDECKEN) + merkeMeldung('Eine Meldung') + await v.zeige(ENTDECKEN) + const aufrufe = v.aufrufe.length + await fx.klick(fx.finde(v.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]')) + v.overlay() + assert.ok(fx.finde(v.wurzel, '.kn-rechnung-offen')) + assert.equal(fx.text(fx.finde(v.wurzel, '[role="status"]')), 'Eine Meldung') // nach dem Schließen noch da, aber nicht neu gemerkt + assert.equal(v.aufrufe.length, aufrufe) // auch kein neuer Abruf der Rechnungen + await v.zeige(ENTDECKEN) + assert.equal(fx.finde(v.wurzel, '[role="status"]'), null) +})) + +test('Erfolg: Meldung merken, ersetzend zu Meine, Meldung genau einmal', mit(async (fx) => { + const u = umgebung(fx) + await u.zeige(ENTDECKEN) + await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]')) + await fx.klick(fx.finde(u.wurzel, '[data-aktion="einschalten"]')) + assert.deepEqual(u.aufrufe.filter(([m]) => m === 'POST'), [['POST', '/api/funktionen/news/einschalten']]) + assert.deepEqual(u.gegangen, [['#funktionen', { ersetzen: true }]]) + u.d = daten({ projekte: [projekt('p1', 'Bäckerei Sonnenschein', { news: feature('setup', { ticketWoid: '100301' }) })] }) + await u.zeige(MEINE) // so ruft der Router danach auf + assert.equal(fx.text(fx.finde(u.wurzel, '[role="status"]')), 'News-Pinnwand ist eingeschaltet. Die Einrichtung läuft jetzt. Ticket #100301.') + await u.zeige(MEINE) + assert.equal(fx.finde(u.wurzel, '[role="status"]'), null) +})) + +test('wechselFunktion (V11): öffnet den Ablauf der Funktion, einmal, nur mit Wartungs-Abo', mit(async (fx) => { + const aboMit = (key) => ({ subscription: { wechselFunktion: { key, projectId: 'p1' } } }) + const abgefragt = (u) => u.aufrufe.some(([, p]) => p === '/api/abo') + let u = umgebung(fx, { abo: aboMit('news') }) + await u.zeige(MEINE) + assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="news"]')) // Bestätigung, kein Sprung + assert.deepEqual(u.gegangen, []) + await u.zeige(MEINE) // zweiter Aufruf: nicht noch einmal in die Funktion + assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]]) + vergissMerker() + u = umgebung(fx, { abo: aboMit('termine') }); await u.zeige(MEINE) + assert.deepEqual(u.gegangen, [['#termine?projekt=p1']]) // Fragebogen zuerst + vergissMerker() + u = umgebung(fx, { d: daten({ abo: { produkt: 'hosting' } }), abo: aboMit('news') }); await u.zeige(MEINE) + assert.equal(abgefragt(u), false) + vergissMerker() + for (const abo of [aboMit('gibtsnicht'), new Error('weg')]) { + u = umgebung(fx, { abo }); await u.zeige(MEINE) + assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]]) // ungültig oder Fehler: wie ohne Wechsel + } +})) + +test('wechselFunktion: nicht, wenn die Website die Funktion schon hat, die Website fehlt oder die Funktion nicht verfügbar ist', mit(async (fx) => { + const abo = { subscription: { wechselFunktion: { key: 'news', projectId: 'p1' } } } + const faelle = [ + daten({ projekte: [projekt('p1', 'B', { news: feature('active') })] }), // hat sie schon: die Liste erscheint + daten({ projekte: [projekt('p9', 'Anders')] }), // Website nicht (mehr) da + daten({ katalogEdit: { news: { verfuegbar: false } } }), // C9 + ] + for (const d of faelle) { + vergissMerker() + const u = umgebung(fx, { d, abo }) + await u.zeige(ENTDECKEN) + assert.equal(fx.finde(u.wurzel, 'section.kn-fn-einschalten'), null) + assert.deepEqual(u.gegangen, []) + } +}))