From 987268e2f0cae5c56a3f85a8eb9a8ac8f1f334d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:11:30 +0000 Subject: [PATCH 1/9] Kundenportal: Rechnungsliste kennt das Projekt (Feld projekt aus den Stripe-Metadaten) Co-Authored-By: Claude Sonnet 5.5 --- server/services/customerInvoices.js | 4 ++- test/auftraegeKundenportalRechnungen.test.js | 28 ++++++++++++++++++++ test/customerInvoices.test.js | 1 + 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 test/auftraegeKundenportalRechnungen.test.js diff --git a/server/services/customerInvoices.js b/server/services/customerInvoices.js index d5ff638..d910594 100644 --- a/server/services/customerInvoices.js +++ b/server/services/customerInvoices.js @@ -59,7 +59,8 @@ const visibleToCustomer = (raw) => ISSUED.has(raw.status) && (raw.total ?? 0) > /** * Stripe-Rechnung in der Form des Kunden-Tabs (wie sanitizeInvoice fuer InvoiceNinja, Betraege in Euro). Offen sind * "open" und "uncollectible" (Stripe nimmt die Zahlung weiter an); ueberfaellig, wenn die Faelligkeit vor dem - * Berliner Tag today (YYYY-MM-DD) liegt. + * Berliner Tag today (YYYY-MM-DD) liegt. `projekt` ist der Projektname aus den Stripe-Metadaten (Projektrechnungen aus dem + * WOMS), sonst leer. */ export function customerStripeInvoice(raw, { today = berlinDate() } = {}) { const outstanding = raw.status === 'open' || raw.status === 'uncollectible' @@ -83,6 +84,7 @@ export function customerStripeInvoice(raw, { today = berlinDate() } = {}) { overdue, portalUrl: raw.hosted_invoice_url || '', hasPdf: Boolean(raw.invoice_pdf), + projekt: raw.metadata?.projektName || '', } } diff --git a/test/auftraegeKundenportalRechnungen.test.js b/test/auftraegeKundenportalRechnungen.test.js new file mode 100644 index 0000000..295fae1 --- /dev/null +++ b/test/auftraegeKundenportalRechnungen.test.js @@ -0,0 +1,28 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTestEnv } from './support/testEnv.js' + +// Reiner Test von customerStripeInvoice: config.js liest die Umgebung beim ersten Import, die Adressen werden nie angefragt. +useTestEnv({ appwriteUrl: 'http://127.0.0.1:9', stripeUrl: 'http://127.0.0.1:9' }) +const { customerStripeInvoice } = await import('../server/services/customerInvoices.js') + +const roh = (over = {}) => ({ + id: 'in_1', number: 'WEBKLAR-0009', status: 'open', effective_at: 1_791_000_000, due_date: 1_792_000_000, + total: 50000, amount_remaining: 50000, hosted_invoice_url: 'https://invoice.stripe.com/i/in_1', + invoice_pdf: 'https://pay.stripe.com/invoice/in_1/pdf', metadata: {}, ...over, +}) + +test('Rechnung der Kunden-Liste: projekt kommt aus metadata.projektName, sonst leer', () => { + assert.equal(customerStripeInvoice(roh({ metadata: { projektName: 'Website Anelia Wolf' } })).projekt, 'Website Anelia Wolf') + assert.equal(customerStripeInvoice(roh()).projekt, '') + assert.equal(customerStripeInvoice(roh({ metadata: { webklarCustomerId: 'c1', art: 'abo' } })).projekt, '') + const ohneMetadaten = roh() + delete ohneMetadaten.metadata + assert.equal(customerStripeInvoice(ohneMetadaten).projekt, '') +}) + +test('Rechnung der Kunden-Liste: die bisherigen Felder bleiben, projekt kommt dazu', () => { + assert.deepEqual(Object.keys(customerStripeInvoice(roh())).sort(), [ + 'amount', 'balance', 'cancelled', 'date', 'dueDate', 'hasPdf', 'id', 'number', 'overdue', 'paid', 'portalUrl', 'projekt', 'source', 'status', + ]) +}) diff --git a/test/customerInvoices.test.js b/test/customerInvoices.test.js index baae83e..af171dd 100644 --- a/test/customerInvoices.test.js +++ b/test/customerInvoices.test.js @@ -184,6 +184,7 @@ test('Liste: Felder einer bezahlten Abo-Rechnung (Betraege in Euro, Link zur Rec overdue: false, portalUrl: 'https://invoice.stripe.com/i/in_c1_abo', hasPdf: true, + projekt: '', }) }) From e6a0fc9acf8a61fa177d1b07f00488f5bed6994d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:34 +0000 Subject: [PATCH 2/9] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20reine=20Ans?= =?UTF-8?q?ichtslogik=20(Token,=20Betr=C3=A4ge,=20Zust=C3=A4nde,=20Status,?= =?UTF-8?q?=20AB-Nummer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/auftrag-ansicht.js | 260 ++++++++++++++++++++++ test/auftraegeKundenportalAnsicht.test.js | 211 ++++++++++++++++++ test/auftraegeKundenportalDaten.js | 65 ++++++ 3 files changed, 536 insertions(+) create mode 100644 public/auftrag-ansicht.js create mode 100644 test/auftraegeKundenportalAnsicht.test.js create mode 100644 test/auftraegeKundenportalDaten.js diff --git a/public/auftrag-ansicht.js b/public/auftrag-ansicht.js new file mode 100644 index 0000000..c84c239 --- /dev/null +++ b/public/auftrag-ansicht.js @@ -0,0 +1,260 @@ +// Reine Ansichtslogik der Auftragsbestätigung (AB) im Kundenportal: Token, Beträge, Daten, Zustände, Status, AB-Nummer +// und alle Seitentexte. Ohne Importe, ohne DOM, ohne fetch; jede Funktion verträgt fehlende Felder. +// Die Anrede wählt der Aufrufer über ANREDE_SIE (öffentliche Seite, Widerrufsseite) oder ANREDE_DU (angemeldetes Portal). + +export const TOKEN_MUSTER = /^[A-Za-z0-9_-]{43}$/ +export const KONTAKT = { email: 'support@webklar.com', telefon: '0176 23726355' } +export const BESTAETIGUNG_FALLBACK = 'Ich bestätige den Auftrag zu diesen Bedingungen.' +export const ANREDE_SIE = 'sie' +export const ANREDE_DU = 'du' + +/** Texte, die in beiden Anreden gleich lauten (weder Du- noch Sie-Formen). */ +export const TEXTE_GEMEINSAM = { + titelOffen: 'Auftragsbestätigung', + leistung: 'Leistungsumfang', + aufwand: 'Abrechnung nach Aufwand', + zahlung: 'Zahlung', + termine: 'Termine', + unterlagen: 'Unterlagen', + spalten: ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'], + gesamtpreis: 'Gesamtpreis', + linkPdf: 'Auftragsbestätigung als PDF ansehen', + linkAgb: 'Allgemeine Geschäftsbedingungen (PDF)', + freiwillig: '(freiwillig)', + knopf: 'Auftrag bestätigen', + sendet: 'Wird gesendet …', + ansichtsmodus: 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.', + erneut: 'Erneut versuchen', + kartePdf: 'PDF ansehen', + karteDetails: 'Details ansehen', + karteDetailsBestaetigen: 'Details ansehen und bestätigen', +} + +/** Öffentliche Seite und Widerrufsseite (Sie-Form); enthält auch die Schlüssel, die das Portal in Du überschreibt. */ +export const TEXTE_SIE = { + laedt: 'Ihre Auftragsbestätigung wird geladen …', + geladen: 'Ihre Auftragsbestätigung wurde geladen.', + titelBestaetigt: 'Ihr Auftrag ist bestätigt.', + intro: 'Bitte prüfen Sie die Angaben. Mit einem Klick bestätigen Sie den Auftrag.', + kontaktVor: 'Sie erreichen uns unter', + keinJs: 'Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.', + abNummerUngueltig: 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.', + formular: 'Ihre Bestätigung', + speicherHinweis: 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.', + fruehbeginnWunsch: 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.', + fehler: { + keine: 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.', + haken: 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.', + anmeldung: 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.', + gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.', + zuviele: 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.', + sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.', + }, +} + +/** Angemeldetes Portal (Du-Form): nur die Schlüssel, die anders lauten. */ +export const TEXTE_DU = { + formular: 'Deine Bestätigung', + speicherHinweis: 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.', + fruehbeginnWunsch: 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.', + fehler: { + keine: 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.', + haken: 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.', + anmeldung: 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.', + gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.', + zuviele: 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.', + sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.', + }, +} + +/** Texte der gewählten Anrede (ohne Angabe: Sie). */ +export const texte = (anrede = ANREDE_SIE) => ({ ...TEXTE_GEMEINSAM, ...(anrede === ANREDE_DU ? TEXTE_DU : TEXTE_SIE) }) + +/* ------------------------------------------------------------------ Token und Links --- */ + +/** Token aus der Adresse: zuerst das Fragment (so verlinkt K31), dann die Suche (Rückfall). '' = ungültig. */ +export function tokenAusAdresse(suche = '', hash = '') { + for (const roh of [hash, suche]) { + const teil = String(roh ?? '').replace(/^[#?]/, '') + if (!teil) continue + const werte = new URLSearchParams(teil).getAll('t') + if (werte.length === 1 && TOKEN_MUSTER.test(werte[0])) return werte[0] + } + return '' +} + +export const pdfPfad = (token) => `/api/auftrag/${encodeURIComponent(token)}/pdf` + +/** AGB-Link: nur ein PDF unter /rechtliches/, sonst ''. */ +export const agbPfad = (agbUrl) => (typeof agbUrl === 'string' && /^\/rechtliches\/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$/.test(agbUrl) ? agbUrl : '') + +/* ------------------------------------------------------------------ Beträge und Zeiten --- */ + +const EURO = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }) +const BERLIN_TAG = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }) +const BERLIN_ZEIT = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', +}) + +/** Ganze Cent als deutscher Euro-Betrag (negativ mit dem Minuszeichen), sonst '–'. */ +export function euroDe(cents) { + if (!Number.isSafeInteger(cents)) return '–' + const text = EURO.format(cents / 100) + return text.startsWith('-') ? `\u{2212}${text.slice(1)}` : text +} + +/** 'YYYY-MM-DD' oder ISO-Zeitpunkt (Berliner Tag) oder 'TT.MM.JJJJ' als 'TT.MM.JJJJ', sonst ''. */ +export function datumDe(wert) { + if (typeof wert !== 'string') return '' + if (/^\d{2}\.\d{2}\.\d{4}$/.test(wert)) return wert + if (/^\d{4}-\d{2}-\d{2}$/.test(wert)) return `${wert.slice(8, 10)}.${wert.slice(5, 7)}.${wert.slice(0, 4)}` + if (/^\d{4}-\d{2}-\d{2}T/.test(wert)) { + const zeit = new Date(wert) + if (Number.isNaN(zeit.getTime())) return '' + return datumDe(BERLIN_TAG.format(zeit)) + } + return '' +} + +/** 'TT.MM.JJJJ, HH:MM Uhr' in Berliner Zeit, sonst ''. */ +export function zeitpunktDe(iso) { + if (typeof iso !== 'string' || !iso) return '' + const zeit = new Date(iso) + if (Number.isNaN(zeit.getTime())) return '' + const teile = Object.fromEntries(BERLIN_ZEIT.formatToParts(zeit).map((teil) => [teil.type, teil.value])) + return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr` +} + +/* ------------------------------------------------------------------ Zustand der AB --- */ + +export const zeigtFruehbeginn = (a) => typeof a?.texte?.fruehbeginn === 'string' && a.texte.fruehbeginn.trim() !== '' + +export const darfBestaetigen = (a) => !a?.bestaetigtAm && (a?.status === 'versendet' || a?.status === 'abgerechnet') + +const STATUS = { + versendet: { text: 'Versendet', ton: 'muted' }, + bestaetigt: { text: 'Bestätigt', ton: 'ok' }, + abgerechnet: { text: 'Abgerechnet', ton: 'ok' }, + storniert: { text: 'Storniert', ton: 'muted' }, + ersetzt: { text: 'Ersetzt', ton: 'muted' }, + widerrufen: { text: 'Widerrufen', ton: 'rot' }, +} + +export function statusAnzeige(a) { + if (a?.kannBestaetigen === true && !a.bestaetigtAm) return { text: 'Bitte bestätigen', ton: 'warn' } + const eintrag = STATUS[a?.status] + return eintrag ? { ...eintrag } : { text: '–', ton: 'muted' } +} + +export function kartenMeta(a) { + const teile = [] + const datum = datumDe(a?.datum) + if (datum) teile.push(`vom ${datum}`) + if (a?.art === 'aufwand') teile.push('nach Aufwand') + else if (Number.isSafeInteger(a?.summeCents)) teile.push(euroDe(a.summeCents)) + return teile.join(' · ') +} + +/** Kopie: zuerst die AB, die noch bestätigt werden kann, dann nach Nummer absteigend (Zeichenvergleich). */ +export function sortiereAuftraege(liste) { + if (!Array.isArray(liste)) return [] + return [...liste].sort((x, y) => { + const a = x?.kannBestaetigen === true ? 0 : 1 + const b = y?.kannBestaetigen === true ? 0 : 1 + if (a !== b) return a - b + const nx = String(x?.nummer ?? '') + const ny = String(y?.nummer ?? '') + return nx < ny ? 1 : nx > ny ? -1 : 0 + }) +} + +/* ------------------------------------------------------------------ Inhalte --- */ + +export function zusammenfassung(a) { + const auftrag = a ?? {} + const aufwand = auftrag.art === 'aufwand' + const kopf = [ + { label: 'Nummer', wert: auftrag.nummer || '' }, + { label: 'Datum', wert: datumDe(auftrag.datum) }, + { label: 'Projekt', wert: auftrag.titel || '' }, + ].filter((zeile) => zeile.wert) + const positionen = (Array.isArray(auftrag.positionen) ? auftrag.positionen : []).map((p) => ({ + beschreibung: String(p?.beschreibung ?? ''), + menge: String(p?.menge ?? ''), + einzelpreis: euroDe(p?.einzelpreisCents), + summe: euroDe(p?.summeCents), + })) + const termine = [ + { label: 'Start', wert: datumDe(auftrag.startAm) }, + { label: 'Voraussichtliche Fertigstellung', wert: datumDe(auftrag.fertigBis) }, + ].filter((zeile) => zeile.wert) + const hinweis = typeof auftrag.texte?.kleinunternehmer === 'string' ? auftrag.texte.kleinunternehmer.trim() : '' + return { + kopf, + art: aufwand ? 'aufwand' : 'festpreis', + positionen, + summe: !aufwand && Number.isSafeInteger(auftrag.summeCents) ? euroDe(auftrag.summeCents) : '', + aufwand: aufwand ? String(auftrag.aufwandText ?? '') : '', + zahlung: String(auftrag.zahlungsplanText ?? ''), + termine, + hinweis, + } +} + +export function bestaetigtZeilen(a, anrede = ANREDE_SIE) { + if (!a?.bestaetigtAm) return [] + const zeitpunkt = zeitpunktDe(a.bestaetigtAm) + const zeilen = [zeitpunkt ? `Bestätigt am ${zeitpunkt}` : 'Bestätigt'] + if (a.kundenart === 'privat') { + if (a.fruehbeginn === true) zeilen.push(texte(anrede).fruehbeginnWunsch) + else if (datumDe(a.fruehesterBeginn)) zeilen.push(`Wir beginnen frühestens am ${datumDe(a.fruehesterBeginn)}.`) + } + return zeilen +} + +/* ------------------------------------------------------------------ Antworten des Servers --- */ + +const BEENDET_GRUENDE = [['ersetzt', /ersetzt/i], ['storniert', /storniert/i], ['widerrufen', /widerrufen/i]] + +/** Zustand der öffentlichen Seite aus Status und Antwort (nur feste eigene Texte, nie Servertext). */ +export function zustandAusAntwort(status, body) { + if (status === 404) { + return { + zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', + text: 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.', + } + } + if (status === 410) { + const meldung = typeof body?.error === 'string' ? body.error : '' + const grund = BEENDET_GRUENDE.find(([, muster]) => muster.test(meldung))?.[0] || '' + return { + zustand: 'beendet', + titel: grund ? `Diese Auftragsbestätigung wurde ${grund}.` : 'Diese Auftragsbestätigung ist nicht mehr gültig.', + text: grund === 'ersetzt' + ? 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.' + : 'Bei Fragen melden Sie sich gern bei uns.', + } + } + if (status === 429) return { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' } + return { + zustand: 'fehler', titel: 'Das hat leider nicht geklappt', + text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.', + } +} + +/** Meldung beim Bestätigen nach Status (feste eigene Texte der gewählten Anrede). */ +export function fehlerMeldung(status, anrede = ANREDE_SIE) { + const fehler = texte(anrede).fehler + if (status === 0) return fehler.keine + if (status === 400) return fehler.haken + if (status === 401) return fehler.anmeldung + if (status === 403 || status === 409) return fehler.gesperrt + if (status === 429) return fehler.zuviele + return fehler.sonst +} + +/** Freiwillige AB-Nummer der Widerrufsseite: 'AB-JJJJ-NNNN' oder ''. */ +export function abNummerNormalisiert(eingabe) { + const text = String(eingabe ?? '').trim().toUpperCase().replace(/[\s\-\u{2010}-\u{2015}\u{2212}]+/gu, '-') + return /^AB-\d{4}-\d{4}$/.test(text) ? text : '' +} diff --git a/test/auftraegeKundenportalAnsicht.test.js b/test/auftraegeKundenportalAnsicht.test.js new file mode 100644 index 0000000..0f2f436 --- /dev/null +++ b/test/auftraegeKundenportalAnsicht.test.js @@ -0,0 +1,211 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + TOKEN_MUSTER, KONTAKT, ANREDE_SIE, ANREDE_DU, TEXTE_GEMEINSAM, TEXTE_SIE, TEXTE_DU, texte, tokenAusAdresse, euroDe, datumDe, zeitpunktDe, + pdfPfad, agbPfad, zeigtFruehbeginn, darfBestaetigen, statusAnzeige, kartenMeta, zusammenfassung, bestaetigtZeilen, zustandAusAntwort, + fehlerMeldung, sortiereAuftraege, abNummerNormalisiert, +} from '../public/auftrag-ansicht.js' +import { TOKEN, BESTAETIGT_AM, KLEINUNTERNEHMER, ab, privat, bestaetigt, hatDuForm, hatSieForm, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const NB = '\u{a0}' // geschütztes Leerzeichen vor „€“ (wie euroDe auf dem Server) + +test('Token: 43 Zeichen base64url, zuerst aus #t= (so verlinkt K31), sonst aus ?t=, nie mehrdeutig', () => { + assert.equal(TOKEN.length, 43) + assert.match(TOKEN, TOKEN_MUSTER) + assert.equal(tokenAusAdresse('', `#t=${TOKEN}`), TOKEN) + assert.equal(tokenAusAdresse(`?t=${TOKEN}`, ''), TOKEN) + assert.equal(tokenAusAdresse(`?x=1&t=${TOKEN}`, ''), TOKEN) + const anderer = 'y'.repeat(43) + assert.equal(tokenAusAdresse(`?t=${TOKEN}`, `#t=${anderer}`), anderer, 'das Fragment gewinnt') + assert.equal(tokenAusAdresse(`?t=${TOKEN}`, '#t=abc'), TOKEN, 'ungültiges Fragment: weiter mit der Suche') + for (const hash of ['', '#', '#t=', '#t=abc', `#t=${TOKEN}x`, `#t=${TOKEN.slice(0, 42)}!`, `#t=${TOKEN}&t=${TOKEN}`, `#token=${TOKEN}`]) { + assert.equal(tokenAusAdresse('', hash), '', hash) + } + assert.equal(tokenAusAdresse(`?t=${TOKEN}&t=${TOKEN}`, ''), '') + assert.equal(tokenAusAdresse(undefined, undefined), '') +}) + +test('Beträge: ganze Cent als deutscher Euro-Betrag wie auf dem Server', () => { + assert.equal(euroDe(80000), `800,00${NB}€`) + assert.equal(euroDe(123456), `1.234,56${NB}€`) + assert.equal(euroDe(5), `0,05${NB}€`) + assert.equal(euroDe(0), `0,00${NB}€`) + assert.equal(euroDe(-449), `−4,49${NB}€`) + for (const kaputt of [1.5, '800', null, undefined, Number.NaN]) assert.equal(euroDe(kaputt), '–') +}) + +test('Datum und Zeit: Berliner Zeit, unabhängig von der Zeitzone des Browsers', () => { + assert.equal(datumDe('2026-10-06'), '06.10.2026') + assert.equal(datumDe('06.10.2026'), '06.10.2026') + assert.equal(datumDe('2026-10-06T22:30:00.000Z'), '07.10.2026') // 00:30 MESZ am Folgetag + for (const kaputt of ['', null, undefined, 'morgen', '2026-10', 20261006]) assert.equal(datumDe(kaputt), '') + assert.equal(zeitpunktDe(BESTAETIGT_AM), '07.10.2026, 10:15 Uhr') + assert.equal(zeitpunktDe('2026-10-07T08:15:00.000+00:00'), '07.10.2026, 10:15 Uhr') // so liefert Appwrite + assert.equal(zeitpunktDe('2026-12-01T08:15:00Z'), '01.12.2026, 09:15 Uhr') // Winterzeit + assert.equal(zeitpunktDe('2026-10-06T22:05:00Z'), '07.10.2026, 00:05 Uhr') // Mitternacht ohne „24“ + for (const kaputt of ['', null, undefined, 'bald']) assert.equal(zeitpunktDe(kaputt), '') +}) + +test('Links: PDF-Pfad aus dem Token, AGB nur als PDF unter /rechtliches/', () => { + assert.equal(pdfPfad(TOKEN), `/api/auftrag/${TOKEN}/pdf`) + assert.equal(agbPfad('/rechtliches/agb-2026-09-30.pdf'), '/rechtliches/agb-2026-09-30.pdf') + for (const boese of ['https://evil.example/agb.pdf', '//evil.example/agb.pdf', '/rechtliches/../x.pdf', '/rechtliches/agb.pdf?x=1', '/rechtliches/agb.html', 'javascript:alert(1)', '', null, undefined, 5]) { + assert.equal(agbPfad(boese), '', String(boese)) + } +}) + +test('Häkchen: Frühbeginn nur mit Text vom Server; Bestätigen nur, solange nicht bestätigt', () => { + assert.equal(zeigtFruehbeginn(privat()), true) + assert.equal(zeigtFruehbeginn(ab()), false) + assert.equal(zeigtFruehbeginn(privat({ texte: { bestaetigung: 'x', fruehbeginn: ' ' } })), false) + assert.equal(zeigtFruehbeginn({}), false) + assert.equal(darfBestaetigen(ab()), true) + assert.equal(darfBestaetigen(ab({ status: 'abgerechnet' })), true) // Schlussrechnung vor der Bestätigung (Übersicht V2) + assert.equal(darfBestaetigen(bestaetigt(ab())), false) + assert.equal(darfBestaetigen(ab({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM })), false) + for (const status of ['storniert', 'ersetzt', 'widerrufen', 'entwurf']) assert.equal(darfBestaetigen(ab({ status })), false, status) +}) + +test('Status: Beschriftung und Ton; „Bitte bestätigen“ nur mit kannBestaetigen und ohne Bestätigung', () => { + assert.deepEqual(statusAnzeige({ status: 'versendet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' }) + assert.deepEqual(statusAnzeige({ status: 'abgerechnet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' }) + assert.deepEqual(statusAnzeige({ status: 'versendet' }), { text: 'Versendet', ton: 'muted' }) + assert.deepEqual(statusAnzeige({ status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM, kannBestaetigen: false }), { text: 'Bestätigt', ton: 'ok' }) + assert.deepEqual(statusAnzeige({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM }), { text: 'Abgerechnet', ton: 'ok' }) + assert.deepEqual(statusAnzeige({ status: 'storniert' }), { text: 'Storniert', ton: 'muted' }) + assert.deepEqual(statusAnzeige({ status: 'ersetzt' }), { text: 'Ersetzt', ton: 'muted' }) + assert.deepEqual(statusAnzeige({ status: 'widerrufen' }), { text: 'Widerrufen', ton: 'rot' }) + assert.deepEqual(statusAnzeige({ status: 'unbekannt' }), { text: '–', ton: 'muted' }) + assert.deepEqual(statusAnzeige(undefined), { text: '–', ton: 'muted' }) +}) + +test('Karten-Zeile und Sortierung der Portalliste', () => { + assert.equal(kartenMeta(ab()), `vom 06.10.2026 · 800,00${NB}€`) + assert.equal(kartenMeta(ab({ art: 'aufwand', positionen: [], summeCents: null })), 'vom 06.10.2026 · nach Aufwand') + assert.equal(kartenMeta(ab({ datum: '' })), `800,00${NB}€`) + assert.equal(kartenMeta({}), '') + const liste = [ + { nummer: 'AB-2026-0001', kannBestaetigen: false }, { nummer: 'AB-2026-0003', kannBestaetigen: false }, + { nummer: 'AB-2026-0002', kannBestaetigen: true }, { nummer: 'AB-2025-0009', kannBestaetigen: false }, + ] + assert.deepEqual(sortiereAuftraege(liste).map((a) => a.nummer), ['AB-2026-0002', 'AB-2026-0003', 'AB-2026-0001', 'AB-2025-0009']) + assert.equal(liste[0].nummer, 'AB-2026-0001', 'die Eingabe bleibt unverändert') + assert.deepEqual(sortiereAuftraege(undefined), []) +}) + +test('Zusammenfassung (Festpreis): Kopf, Positionen, Summe, Zahlungsplan, Termine, § 19 aus der API', () => { + assert.deepEqual(zusammenfassung(ab()), { + kopf: [{ label: 'Nummer', wert: 'AB-2026-0002' }, { label: 'Datum', wert: '06.10.2026' }, { label: 'Projekt', wert: 'Website Anelia Wolf' }], + art: 'festpreis', + positionen: [ + { beschreibung: 'Erstellung der Website anelia-wolf.com', menge: '1', einzelpreis: `600,00${NB}€`, summe: `600,00${NB}€` }, + { beschreibung: 'Texte und Bildbearbeitung', menge: '2', einzelpreis: `100,00${NB}€`, summe: `200,00${NB}€` }, + ], + summe: `800,00${NB}€`, + aufwand: '', + zahlung: ab().zahlungsplanText, + termine: [{ label: 'Start', wert: '12.10.2026' }, { label: 'Voraussichtliche Fertigstellung', wert: '30.11.2026' }], + hinweis: KLEINUNTERNEHMER, + }) +}) + +test('Zusammenfassung (Aufwand): Aufwandstext statt Positionen; Termine nur, wenn gesetzt; ohne Satz der API kein § 19-Hinweis; leere Eingabe wirft nicht', () => { + const z = zusammenfassung(ab({ + art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', + zahlungsplanText: 'Monatlich nach Aufwand.', startAm: '2026-10-12', fertigBis: '', + })) + assert.equal(z.art, 'aufwand') + assert.deepEqual(z.positionen, []) + assert.equal(z.summe, '') + assert.equal(z.aufwand, '100,00 € pro Stunde in Schritten von 15 Minuten.') + assert.equal(z.zahlung, 'Monatlich nach Aufwand.') + assert.deepEqual(z.termine, [{ label: 'Start', wert: '12.10.2026' }]) + assert.equal(z.hinweis, KLEINUNTERNEHMER) + assert.equal(zusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })).hinweis, '', 'keine eigene Ersatzkonstante: der Satz kommt von der API') + assert.deepEqual(zusammenfassung({}), { kopf: [], art: 'festpreis', positionen: [], summe: '', aufwand: '', zahlung: '', termine: [], hinweis: '' }) + assert.doesNotThrow(() => zusammenfassung(undefined)) +}) + +test('Bestätigt-Zeilen: Zeitpunkt, bei Privatpersonen Frühbeginn bzw. frühester Beginn, in beiden Anreden', () => { + assert.deepEqual(bestaetigtZeilen(ab()), []) + assert.deepEqual(bestaetigtZeilen(bestaetigt(ab())), ['Bestätigt am 07.10.2026, 10:15 Uhr']) + assert.deepEqual(bestaetigtZeilen(bestaetigt(ab({ fruehesterBeginn: '2026-10-21' }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'], 'Unternehmen: keine Zeile zum Beginn') + assert.deepEqual(bestaetigtZeilen(bestaetigt(privat())), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.']) + const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) + assert.deepEqual(bestaetigtZeilen(wunsch), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.deepEqual(bestaetigtZeilen(wunsch, ANREDE_DU), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.deepEqual(bestaetigtZeilen(bestaetigt(privat({ fruehesterBeginn: null }))), ['Bestätigt am 07.10.2026, 10:15 Uhr']) + assert.deepEqual(bestaetigtZeilen({ bestaetigtAm: 'kaputt' }), ['Bestätigt']) +}) + +test('Zustände aus der Serverantwort: feste Texte, der 410-Grund nur aus dem Stichwort', () => { + const kontakt = 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.' + assert.deepEqual(zustandAusAntwort(404, { error: 'Dieser Link ist ungültig oder abgelaufen.' }), { zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', text: kontakt }) + assert.deepEqual(zustandAusAntwort(410, { error: 'Diese Auftragsbestätigung wurde ersetzt.' }), { + zustand: 'beendet', titel: 'Diese Auftragsbestätigung wurde ersetzt.', text: 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.', + }) + for (const grund of ['storniert', 'widerrufen']) { + assert.deepEqual(zustandAusAntwort(410, { error: `Diese Auftragsbestätigung wurde ${grund}.` }), { + zustand: 'beendet', titel: `Diese Auftragsbestätigung wurde ${grund}.`, text: 'Bei Fragen melden Sie sich gern bei uns.', + }) + } + for (const body of [{ error: `Irgendwas mit ${TOKEN}` }, {}, undefined, null]) { // fremder Text (auch mit Token) wird nie angezeigt + const z = zustandAusAntwort(410, body) + assert.equal(z.titel, 'Diese Auftragsbestätigung ist nicht mehr gültig.') + assert.equal(z.text, 'Bei Fragen melden Sie sich gern bei uns.') + } + assert.deepEqual(zustandAusAntwort(429, { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }), { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' }) + for (const status of [0, 400, 401, 500, 502, 503]) { + assert.deepEqual(zustandAusAntwort(status, { error: 'Interner Fehler.' }), { + zustand: 'fehler', titel: 'Das hat leider nicht geklappt', text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.', + }) + } +}) + +test('Fehlermeldungen beim Bestätigen: feste eigene Texte nach Status, Sie auf der Seite und Du im Portal', () => { + assert.equal(fehlerMeldung(0), 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.') + assert.equal(fehlerMeldung(400), 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.') + assert.equal(fehlerMeldung(401), 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.') + for (const status of [403, 409]) assert.equal(fehlerMeldung(status), 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.') + assert.equal(fehlerMeldung(429), 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.') + for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.') + assert.equal(fehlerMeldung(0, ANREDE_DU), 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.') + assert.equal(fehlerMeldung(400, ANREDE_DU), 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.') + assert.equal(fehlerMeldung(401, ANREDE_DU), 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.') + for (const status of [403, 409]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.') + assert.equal(fehlerMeldung(429, ANREDE_DU), 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.') + for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.') +}) + +const werte = (objekt) => Object.values(objekt).flatMap((wert) => (wert && typeof wert === 'object' ? werte(wert) : [wert])).filter((wert) => typeof wert === 'string') + +test('Anrede: gemeinsame Texte neutral, öffentliche Seite Sie, Portal Du; jede Du-Variante hat ihr Sie-Gegenstück', () => { + assert.equal(ANREDE_SIE, 'sie') + assert.equal(ANREDE_DU, 'du') + for (const text of werte(TEXTE_GEMEINSAM)) assert.ok(!hatDuForm(text) && !hatSieForm(text), text) + for (const text of werte(TEXTE_SIE)) assert.ok(!hatDuForm(text), text) + for (const text of werte(TEXTE_DU)) assert.ok(!hatSieForm(text), text) + assert.deepEqual(Object.keys(TEXTE_DU).sort(), ['fehler', 'formular', 'fruehbeginnWunsch', 'speicherHinweis']) + for (const schluessel of Object.keys(TEXTE_DU)) assert.ok(schluessel in TEXTE_SIE, schluessel) + assert.deepEqual(Object.keys(TEXTE_DU.fehler), Object.keys(TEXTE_SIE.fehler)) + assert.equal(texte().formular, 'Ihre Bestätigung', 'ohne Angabe gilt Sie') + assert.equal(texte(ANREDE_SIE).speicherHinweis, 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.equal(texte(ANREDE_DU).formular, 'Deine Bestätigung') + assert.equal(texte(ANREDE_DU).speicherHinweis, 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.equal(texte(ANREDE_DU).knopf, 'Auftrag bestätigen', 'gemeinsame Texte gibt es in beiden Anreden') + assert.equal(texte(ANREDE_DU).ansichtsmodus, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') +}) + +test('AB-Nummer: Schreibweisen der Praxis werden zu AB-JJJJ-NNNN, alles andere zu leer', () => { + assert.equal(abNummerNormalisiert('AB-2026-0001'), 'AB-2026-0001') + assert.equal(abNummerNormalisiert(' ab-2026-0001 '), 'AB-2026-0001') + assert.equal(abNummerNormalisiert('ab 2026 0001'), 'AB-2026-0001') + assert.equal(abNummerNormalisiert('AB – 2026 – 0001'), 'AB-2026-0001') // Halbgeviertstrich mit Leerzeichen + assert.equal(abNummerNormalisiert('AB−2026−0001'), 'AB-2026-0001') // Minuszeichen + for (const kaputt of ['', ' ', 'AB-2026-01', 'AB-26-0001', 'RE-2026-0001', 'AB-2026-0001-1', 'AB20260001', undefined, null]) assert.equal(abNummerNormalisiert(kaputt), '', String(kaputt)) +}) + +test('Quelltext und Kontakt: reines Modul ohne Importe, fetch und DOM; Kontaktdaten wie in den Rechtstexten', () => { + pruefeQuelltext('auftrag-ansicht.js', { mitTexten: true }) + assert.deepEqual(KONTAKT, { email: 'support@webklar.com', telefon: '0176 23726355' }) +}) diff --git a/test/auftraegeKundenportalDaten.js b/test/auftraegeKundenportalDaten.js new file mode 100644 index 0000000..a55685a --- /dev/null +++ b/test/auftraegeKundenportalDaten.js @@ -0,0 +1,65 @@ +// Gemeinsame Testdaten und Quelltext-Regeln des Strangs L6 (kein Test, daher nicht *.test.js). +// Formen laut Übersicht V3 (AuftragOeffentlich) und V5 (Liste im Portal: plus id und kannBestaetigen). +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +export const TOKEN = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG' // 43 Zeichen wie ein echter Token (base64url) +export const BESTAETIGT_AM = '2026-10-07T08:15:00.000Z' // = 07.10.2026, 10:15 Uhr Berliner Zeit (MESZ) +export const KLEINUNTERNEHMER = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' // wie AB_TEXTE.kleinunternehmer; die API liefert ihn als texte.kleinunternehmer + +/** Festpreis-AB eines Unternehmens, gesendet, noch nicht bestätigt. */ +export const ab = (over = {}) => ({ + nummer: 'AB-2026-0002', datum: '2026-10-06', titel: 'Website Anelia Wolf', art: 'festpreis', + positionen: [ + { beschreibung: 'Erstellung der Website anelia-wolf.com', menge: 1, einzelpreisCents: 60000, summeCents: 60000 }, + { beschreibung: 'Texte und Bildbearbeitung', menge: 2, einzelpreisCents: 10000, summeCents: 20000 }, + ], + summeCents: 80000, stundensatzCents: null, schaetzungMinuten: null, aufwandText: null, + zahlungsplanText: 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.', + startAm: '2026-10-12', fertigBis: '2026-11-30', kundenart: 'unternehmen', status: 'versendet', + bestaetigtAm: null, fruehbeginn: false, fruehesterBeginn: null, agbUrl: '/rechtliches/agb-2026-09-30.pdf', + texte: { bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', fruehbeginn: null, kleinunternehmer: KLEINUNTERNEHMER }, + ...over, +}) + +/** Dieselbe AB für eine Privatperson (zweites, freiwilliges Häkchen; Texte sind Testtexte, nicht die der Rechtsrunde). */ +export const privat = (over = {}) => ab({ + kundenart: 'privat', fruehesterBeginn: '2026-10-21', + texte: { + bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', + fruehbeginn: 'Ich verlange, dass Sie schon vor Ablauf der Widerrufsfrist mit der Arbeit beginnen.', + kleinunternehmer: KLEINUNTERNEHMER, + }, + ...over, +}) + +/** Die übergebene AB im Zustand „bestätigt“. */ +export const bestaetigt = (auftrag) => ({ ...auftrag, status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM }) + +/** Eintrag der Portal-Liste (GET /api/auftraege): AB plus id und kannBestaetigen. */ +export const eintrag = (over = {}) => ({ id: 'ab1', ...ab(), kannBestaetigen: true, ...over }) + +const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1') +const DU = /\b(?:du|dein(?:e[nmrs]?)?|dich|dir)\b/i +const SIE = /\b(?:Sie|Ihr(?:e[nmrs]?)?|Ihnen)\b/ +/** Steht eine Du- bzw. Sie-Form im Text? (Anrede-Prüfungen der Tests) */ +export const hatDuForm = (text) => DU.test(String(text)) +export const hatSieForm = (text) => SIE.test(String(text)) + +/** + * Quelltext-Regeln für jedes Modul public/auftrag*.js; gibt den Quelltext ohne Kommentare zurück. + * mitTexten: nur auftrag-ansicht.js enthält Kundentexte (ihre Anrede prüft der Test selbst); alle anderen Module enthalten weder Du- noch Sie-Formen. + */ +export function pruefeQuelltext(datei, { fetchAnzahl = 0, importe = [], mitTexten = false } = {}) { + const quelle = ohneKommentare(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8')) + assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(|new Function/, `${datei}: Daten nie als HTML`) + assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie|indexedDB/, `${datei}: kein Browserspeicher`) + assert.doesNotMatch(quelle, /\bconsole\./, `${datei}: nichts ins Protokoll (der Token!)`) + assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: keine Browser-Rückfragen`) + assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`) + assert.ok(!quelle.includes(String.fromCodePoint(0xa0)), `${datei}: kein verstecktes geschütztes Leerzeichen im Quelltext`) + if (!mitTexten) assert.ok(!DU.test(quelle) && !hatSieForm(quelle), `${datei}: keine Kundentexte im Modul (sie stehen in auftrag-ansicht.js; Anrede über ANREDE_SIE/ANREDE_DU)`) + assert.equal([...quelle.matchAll(/\bfetch\(/g)].length, fetchAnzahl, `${datei}: Anzahl fetch`) + assert.deepEqual([...quelle.matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1]), importe, `${datei}: Importe`) + return quelle +} From bd87bb115c13460831e95941710fb17ac0d174e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:24 +0000 Subject: [PATCH 3/9] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20DOM-Baustei?= =?UTF-8?q?ne=20(Zusammenfassung,=20Best=C3=A4tigen=20mit=20H=C3=A4kchen,?= =?UTF-8?q?=20Sperre)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/auftrag-dom.js | 126 +++++++++++++ test/auftraegeKundenportalDom.test.js | 249 ++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 public/auftrag-dom.js create mode 100644 test/auftraegeKundenportalDom.test.js diff --git a/public/auftrag-dom.js b/public/auftrag-dom.js new file mode 100644 index 0000000..6e00d74 --- /dev/null +++ b/public/auftrag-dom.js @@ -0,0 +1,126 @@ +/* Auftragsbestätigung im Kundenportal: DOM-Bausteine (Zusammenfassung, Bestätigt-Block, Unterlagen, Bestätigen-Formular). + Kundentext und Servertext kommen ausschließlich über textContent in die Seite (h), nie über innerHTML. + Alle Texte stehen in auftrag-ansicht.js; die Anrede (Sie auf der öffentlichen Seite, Du im Portal) wählt der Aufrufer. */ +import { ANREDE_SIE, BESTAETIGUNG_FALLBACK, bestaetigtZeilen, fehlerMeldung, texte, zeigtFruehbeginn, zusammenfassung } from './auftrag-ansicht.js' + +/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */ +export function h(tag, attrs, ...kinder) { + const el = document.createElement(tag) + for (const [name, wert] of Object.entries(attrs || {})) { + if (wert === undefined || wert === null || wert === false) continue + if (name === 'class') el.className = wert + else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert) + else el.setAttribute(name, wert === true ? '' : wert) + } + for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind) + return el +} + +const zeile = (label, wert) => h('div', { class: 'ab-zeile' }, h('dt', null, label), h('dd', null, wert)) +const liste = (zeilen) => h('dl', { class: 'ab-liste' }, zeilen.map((z) => zeile(z.label, z.wert))) + +/** Zusammenfassung der AB (Kopf, Leistung bzw. Aufwand, Zahlung, Termine); ebene = Überschriftenebene der Abschnitte. */ +export function renderZusammenfassung(auftrag, { ebene = 2 } = {}) { + const t = texte(ANREDE_SIE) + const z = zusammenfassung(auftrag) + const kopfzeile = (text) => h(`h${ebene}`, { class: 'ab-h2' }, text) + const abschnitt = (name, titel, ...inhalt) => h('section', { class: 'ab-abschnitt', 'data-abschnitt': name }, kopfzeile(titel), ...inhalt) + const hinweis = z.hinweis ? h('p', { class: 'ab-hinweis', 'data-hinweis': true }, z.hinweis) : null + + const teile = [] + if (z.kopf.length) teile.push(liste(z.kopf)) + if (z.art === 'aufwand') { + teile.push(abschnitt('aufwand', t.aufwand, h('p', { class: 'ab-text', 'data-aufwand': true }, z.aufwand), hinweis)) + } else { + const tabelle = z.positionen.length + ? h('table', { class: 'ab-tabelle', 'data-positionen': true }, + h('thead', null, h('tr', null, t.spalten.map((spalte) => h('th', { scope: 'col' }, spalte)))), + h('tbody', null, z.positionen.map((p) => h('tr', { 'data-position': true }, + [p.beschreibung, p.menge, p.einzelpreis, p.summe].map((wert, i) => h('td', { 'data-label': t.spalten[i] }, wert)))))) + : null + const gesamt = z.summe ? h('p', { class: 'ab-gesamt', 'data-gesamt': true }, h('span', null, t.gesamtpreis), ' ', h('strong', null, z.summe)) : null + teile.push(abschnitt('leistung', t.leistung, tabelle, gesamt, hinweis)) + } + if (z.zahlung) teile.push(abschnitt('zahlung', t.zahlung, h('p', { class: 'ab-text', 'data-zahlung': true }, z.zahlung))) + if (z.termine.length) teile.push(abschnitt('termine', t.termine, liste(z.termine))) + return h('div', { class: 'ab-zusammenfassung', 'data-art': z.art }, teile) +} + +/** Block „bestätigt“ (Zeitpunkt, bei Privatpersonen Zeile zum Beginn); null ohne Bestätigung. */ +export function renderBestaetigtBlock(auftrag, { anrede = ANREDE_SIE } = {}) { + const zeilen = bestaetigtZeilen(auftrag, anrede) + if (!zeilen.length) return null + return h('div', { class: 'ab-bestaetigt', 'data-bestaetigt': true, role: 'status', tabindex: '-1' }, + zeilen.map((text, i) => h('p', null, i === 0 ? h('strong', null, text) : text))) +} + +/** Links auf die AB als PDF und die AGB (neuer Tab); null ohne Ziele. */ +export function renderDokumente({ pdfHref = '', agbHref = '' } = {}) { + const t = texte(ANREDE_SIE) + const link = (art, href, text) => h('li', null, h('a', { class: 'ab-link', 'data-link': art, href, target: '_blank', rel: 'noopener noreferrer' }, text)) + const eintraege = [pdfHref ? link('pdf', pdfHref, t.linkPdf) : null, agbHref ? link('agb', agbHref, t.linkAgb) : null].filter(Boolean) + return eintraege.length ? h('ul', { class: 'ab-links' }, eintraege) : null +} + +/** + * Formular zum Bestätigen: ein Häkchen, bei Privatpersonen ein zweites, freiwilliges (Frühbeginn). Sendet genau einmal: + * senden(koerper) → { ok: true, auftrag } | { ok: false, status, body }; nachErfolg(auftrag); nachEnde(status, body) bei 404/410. + * gesperrt (Text) = Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar. + */ +export function renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt = '', anrede = ANREDE_SIE } = {}) { + const t = texte(anrede) + let busy = false + const haken1 = h('input', { type: 'checkbox', 'data-feld': 'bestaetigung', required: true, disabled: Boolean(gesperrt) }) + const haken2 = zeigtFruehbeginn(auftrag) ? h('input', { type: 'checkbox', 'data-feld': 'fruehbeginn', disabled: Boolean(gesperrt) }) : null + const fehler = h('p', { class: 'ab-fehler', 'data-fehler': true, role: 'alert', hidden: true }) + const knopf = h('button', { type: 'submit', class: 'ab-knopf', 'data-aktion': 'bestaetigen', disabled: true }, t.knopf) + + const aktualisiere = () => { knopf.disabled = Boolean(gesperrt) || busy || !haken1.checked } + + const form = h('form', { + class: 'ab-bestaetigen', + novalidate: true, + onsubmit: async (ev) => { + ev.preventDefault() + if (busy || gesperrt || !haken1.checked) return + busy = true + fehler.hidden = true + knopf.disabled = true + knopf.setAttribute('aria-busy', 'true') + knopf.textContent = t.sendet + let ergebnis + try { + ergebnis = await senden({ bestaetigt: true, fruehbeginn: Boolean(haken2?.checked) }) + } catch { + ergebnis = { ok: false, status: 0, body: {} } + } + knopf.removeAttribute('aria-busy') + knopf.textContent = t.knopf + if (ergebnis?.ok) { + nachErfolg?.(ergebnis.auftrag) + return + } + const status = ergebnis?.status ?? 0 + if (status === 404 || status === 410) { + nachEnde?.(status, ergebnis?.body) + return + } + busy = false + aktualisiere() + fehler.textContent = fehlerMeldung(status, anrede) + fehler.hidden = false + }, + }, + h('fieldset', { class: 'ab-feldgruppe' }, + h('legend', null, t.formular), + h('label', { class: 'ab-haken' }, haken1, h('span', null, auftrag?.texte?.bestaetigung || BESTAETIGUNG_FALLBACK)), + haken2 ? h('label', { class: 'ab-haken' }, haken2, h('span', null, auftrag.texte.fruehbeginn, ' ', h('small', { class: 'ab-frei' }, t.freiwillig))) : null), + fehler, + knopf, + h('p', { class: 'ab-notiz' }, t.speicherHinweis), + gesperrt ? h('p', { class: 'ab-gesperrt', 'data-gesperrt': true }, gesperrt) : null) + + haken1.addEventListener('change', aktualisiere) + aktualisiere() + return form +} diff --git a/test/auftraegeKundenportalDom.test.js b/test/auftraegeKundenportalDom.test.js new file mode 100644 index 0000000..e5cd2bb --- /dev/null +++ b/test/auftraegeKundenportalDom.test.js @@ -0,0 +1,249 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { renderZusammenfassung, renderBestaetigtBlock, renderDokumente, renderBestaetigung } from '../public/auftrag-dom.js' +import { KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js' +import { ANREDE_DU } from '../public/auftrag-ansicht.js' + +const NB = '\u{a0}' +const mit = (fn) => async () => { + const fx = installFakeDom() + try { + await fn(fx) + } finally { + fx.restore() + } +} +const paare = (fx, baum) => fx.alle(baum, '.ab-zeile').map((z) => [fx.finde(z, 'dt').textContent, fx.finde(z, 'dd').textContent]) + +test('Zusammenfassung (Festpreis): Kopfzeilen, Positionen mit Spaltenmarken, Gesamtpreis, § 19, Zahlung, Termine', mit(async (fx) => { + const el = renderZusammenfassung(ab()) + assert.equal(el.getAttribute('data-art'), 'festpreis') + assert.deepEqual(paare(fx, el).slice(0, 3), [['Nummer', 'AB-2026-0002'], ['Datum', '06.10.2026'], ['Projekt', 'Website Anelia Wolf']]) + const zeilen = fx.alle(el, '[data-position]') + assert.equal(zeilen.length, 2) + assert.deepEqual(fx.alle(zeilen[1], 'td').map((td) => td.textContent), ['Texte und Bildbearbeitung', '2', `100,00${NB}€`, `200,00${NB}€`]) + assert.deepEqual(fx.alle(zeilen[1], 'td').map((td) => td.getAttribute('data-label')), ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe']) + assert.deepEqual(fx.alle(el, 'th').map((th) => th.textContent), ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe']) + assert.equal(fx.finde(el, '[data-gesamt] strong').textContent, `800,00${NB}€`) + assert.equal(fx.finde(el, '[data-hinweis]').textContent, KLEINUNTERNEHMER) + assert.equal(fx.finde(el, '[data-zahlung]').textContent, ab().zahlungsplanText) + assert.deepEqual(paare(fx, fx.finde(el, '[data-abschnitt="termine"]')), [['Start', '12.10.2026'], ['Voraussichtliche Fertigstellung', '30.11.2026']]) +})) + +test('Zusammenfassung (Aufwand): Aufwandstext statt Tabelle und Gesamtpreis; ohne Termine kein Abschnitt', mit(async (fx) => { + const a = ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', startAm: '', fertigBis: '' }) + const el = renderZusammenfassung(a) + assert.equal(el.getAttribute('data-art'), 'aufwand') + assert.equal(fx.finde(el, 'table'), null) + assert.equal(fx.finde(el, '[data-gesamt]'), null) + assert.equal(fx.finde(el, '[data-aufwand]').textContent, '100,00 € pro Stunde in Schritten von 15 Minuten.') + assert.equal(fx.finde(el, '[data-hinweis]').textContent, KLEINUNTERNEHMER) + assert.equal(fx.finde(el, '[data-abschnitt="termine"]'), null) +})) + +test('Zusammenfassung: der § 19-Satz kommt nur aus der API; fehlt er, gibt es keinen Hinweis', mit(async (fx) => { + assert.equal(fx.finde(renderZusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })), '[data-hinweis]'), null) +})) + +test('Zusammenfassung: Überschriftenebene wählbar (Seite h2, Portalkarte h4); leere Eingabe wirft nicht', mit(async (fx) => { + assert.deepEqual(fx.alle(renderZusammenfassung(ab()), 'h2').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine']) + const karte = renderZusammenfassung(ab(), { ebene: 4 }) + assert.equal(fx.alle(karte, 'h2').length, 0) + assert.deepEqual(fx.alle(karte, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine']) + assert.doesNotThrow(() => renderZusammenfassung({})) +})) + +test('Fremdtext bleibt Text: Titel, Positionen und Zahlungsplan werden nie als HTML gelesen', mit(async (fx) => { + const boese = ' & fett "Test"' + const lang = 'x'.repeat(200) + const el = renderZusammenfassung(ab({ + titel: boese, zahlungsplanText: boese, summeCents: 200, + positionen: [{ beschreibung: boese, menge: 1, einzelpreisCents: 100, summeCents: 100 }, { beschreibung: lang, menge: 1, einzelpreisCents: 100, summeCents: 100 }], + })) + assert.equal(fx.finde(el, 'img'), null) + assert.equal(fx.finde(el, 'b'), null) + assert.ok(fx.text(el).includes(boese)) + assert.ok(fx.text(el).includes(lang)) +})) + +test('Bestätigt-Block: Zeitpunkt in Berliner Zeit und Zeile zum Beginn; ohne Bestätigung null', mit(async (fx) => { + assert.equal(renderBestaetigtBlock(ab()), null) + const block = renderBestaetigtBlock(bestaetigt(privat())) + assert.equal(block.getAttribute('role'), 'status') + assert.equal(block.getAttribute('tabindex'), '-1') + assert.equal(block.hasAttribute('data-bestaetigt'), true) + assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.']) +})) + +test('Unterlagen: PDF und AGB als Links in neuem Tab, leere Ziele entfallen', mit(async (fx) => { + const links = fx.alle(renderDokumente({ pdfHref: '/api/auftrag/T/pdf', agbHref: '/rechtliches/agb-2026-09-30.pdf' }), 'a') + assert.deepEqual(links.map((a) => [a.getAttribute('data-link'), a.getAttribute('href'), a.textContent]), [ + ['pdf', '/api/auftrag/T/pdf', 'Auftragsbestätigung als PDF ansehen'], + ['agb', '/rechtliches/agb-2026-09-30.pdf', 'Allgemeine Geschäftsbedingungen (PDF)'], + ]) + for (const a of links) { + assert.equal(a.getAttribute('target'), '_blank') + assert.equal(a.getAttribute('rel'), 'noopener noreferrer') + } + assert.equal(fx.alle(renderDokumente({ agbHref: '/rechtliches/agb-2026-09-30.pdf' }), 'a').length, 1) + assert.equal(renderDokumente({}), null) +})) + +/* ------------------------------------------------------------ Bestätigen-Formular --- */ +const OK = () => Promise.resolve({ ok: true, auftrag: bestaetigt(ab()) }) + +function monte(fx, a, { antwort = OK, gesperrt = '', anrede } = {}) { + const aufrufe = [] + const erfolg = [] + const ende = [] + const form = renderBestaetigung(a, { + senden: (koerper) => { aufrufe.push(koerper); return antwort(koerper) }, + nachErfolg: (x) => erfolg.push(x), + nachEnde: (status, body) => ende.push([status, body]), + gesperrt, + anrede, + }) + fx.document.body.append(form) + return { + form, aufrufe, erfolg, ende, + knopf: fx.finde(form, '[data-aktion="bestaetigen"]'), + haken1: fx.finde(form, '[data-feld="bestaetigung"]'), + haken2: fx.finde(form, '[data-feld="fruehbeginn"]'), + fehler: fx.finde(form, '[data-fehler]'), + } +} + +test('Bestätigen (Unternehmen): ein Häkchen mit dem Text vom Server, Knopf erst nach dem Haken, Antwort geht an nachErfolg', mit(async (fx) => { + const t = monte(fx, ab()) + assert.equal(t.haken2, null) + assert.equal(fx.text(fx.finde(t.form, 'label')), 'Ich bestätige den Auftrag zu diesen Bedingungen.') + assert.equal(t.knopf.textContent, 'Auftrag bestätigen') + assert.equal(t.knopf.disabled, true) + await fx.klick(t.haken1) + assert.equal(t.knopf.disabled, false) + await fx.klick(t.haken1) + assert.equal(t.knopf.disabled, true) + await fx.klick(t.haken1) + await fx.klick(t.knopf) + assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: false }]) + assert.equal(t.erfolg.length, 1) + assert.equal(t.erfolg[0].status, 'bestaetigt') + assert.equal(fx.text(fx.finde(t.form, '.ab-notiz')), 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.') +})) + +test('Bestätigen: fehlt der Text vom Server, gilt der feste Wortlaut aus der Übersicht (V7)', mit(async (fx) => { + const t = monte(fx, ab({ texte: {} })) + assert.equal(fx.text(fx.finde(t.form, 'label')), 'Ich bestätige den Auftrag zu diesen Bedingungen.') +})) + +test('Bestätigen (Privatperson): zweites, freiwilliges Häkchen; ohne Haken fruehbeginn false, mit Haken true', mit(async (fx) => { + const a = privat() + for (const [angehakt, soll] of [[false, false], [true, true]]) { + const t = monte(fx, a) + const zweites = fx.alle(t.form, 'label')[1] + assert.equal(fx.text(zweites).startsWith(a.texte.fruehbeginn), true) + assert.match(fx.text(zweites), /\(freiwillig\)$/) + assert.equal(t.haken2.checked, false) + await fx.klick(t.haken1) + assert.equal(t.knopf.disabled, false, 'das zweite Häkchen ist nicht nötig') + if (angehakt) await fx.klick(t.haken2) + await fx.klick(t.knopf) + assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: soll }]) + t.form.remove() + } +})) + +test('Bestätigen: Privatperson ohne Frühbeginn-Text vom Server bekommt kein zweites Häkchen und sendet false', mit(async (fx) => { + const t = monte(fx, privat({ texte: { bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', fruehbeginn: null } })) + assert.equal(t.haken2, null) + await fx.klick(t.haken1) + await fx.klick(t.knopf) + assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: false }]) +})) + +test('Doppelter Absenden-Versuch sendet genau einmal; Knopf zeigt „Wird gesendet …“ und bleibt nach Erfolg gesperrt', mit(async (fx) => { + let fertig + const t = monte(fx, ab(), { antwort: () => new Promise((resolve) => { fertig = resolve }) }) + await fx.klick(t.haken1) + const erster = fx.klick(t.knopf) + assert.equal(t.knopf.textContent, 'Wird gesendet …') + assert.equal(t.knopf.disabled, true) + assert.equal(t.knopf.getAttribute('aria-busy'), 'true') + await fx.sende(t.form, 'submit') // zweiter Versuch (z. B. Enter), während der erste läuft + assert.equal(t.aufrufe.length, 1) + fertig({ ok: true, auftrag: bestaetigt(ab()) }) + await erster + assert.equal(t.erfolg.length, 1) + assert.equal(t.knopf.disabled, true) + await fx.sende(t.form, 'submit') + assert.equal(t.aufrufe.length, 1, 'auch nach dem Erfolg geht nichts mehr raus') +})) + +test('Netzfehler: Meldung, Häkchen bleiben stehen, erneutes Senden möglich', mit(async (fx) => { + const antworten = [{ ok: false, status: 0, body: {} }, { ok: true, auftrag: bestaetigt(privat()) }] + const t = monte(fx, privat(), { antwort: async () => antworten.shift() }) + await fx.klick(t.haken1) + await fx.klick(t.haken2) + await fx.klick(t.knopf) + assert.equal(t.fehler.hidden, false) + assert.equal(t.fehler.textContent, 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.') + assert.equal(t.haken1.checked, true) + assert.equal(t.haken2.checked, true) + assert.equal(t.knopf.disabled, false) + assert.equal(t.knopf.textContent, 'Auftrag bestätigen') + assert.equal(t.erfolg.length, 0) + await fx.klick(t.knopf) + assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: true }, { bestaetigt: true, fruehbeginn: true }]) + assert.equal(t.erfolg.length, 1) +})) + +test('Fehlertexte nach Status; 404 und 410 gehen an nachEnde statt in die Fehlerzeile', mit(async (fx) => { + for (const [status, text] of [ + [429, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.'], + [500, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.'], + [400, 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.'], + ]) { + const t = monte(fx, ab(), { antwort: async () => ({ ok: false, status, body: { error: 'Servertext, der nie erscheint' } }) }) + await fx.klick(t.haken1) + await fx.klick(t.knopf) + assert.equal(t.fehler.textContent, text, String(status)) + t.form.remove() + } + const body = { error: 'Diese Auftragsbestätigung wurde ersetzt.' } + const t = monte(fx, ab(), { antwort: async () => ({ ok: false, status: 410, body }) }) + await fx.klick(t.haken1) + await fx.klick(t.knopf) + assert.deepEqual(t.ende, [[410, body]]) + assert.equal(t.fehler.hidden, true) +})) + +test('Gesperrt (Ansichtsmodus): Häkchen und Knopf gesperrt, Hinweis sichtbar, kein Senden', mit(async (fx) => { + const hinweis = 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.' + const t = monte(fx, privat(), { gesperrt: hinweis }) + assert.equal(fx.alle(t.form, 'input').every((i) => i.disabled), true) + assert.equal(t.knopf.disabled, true) + assert.equal(fx.finde(t.form, '[data-gesperrt]').textContent, hinweis) + await fx.sende(t.form, 'submit') + assert.equal(t.aufrufe.length, 0) +})) + +test('Anrede: im Portal (Du) stehen Überschrift, Hinweis, Fehlertext und die Zeile zum Wunsch in der Du-Form, auf der öffentlichen Seite (Sie) in der Sie-Form', mit(async (fx) => { + const sie = monte(fx, ab()) + assert.equal(fx.text(fx.finde(sie.form, 'legend')), 'Ihre Bestätigung') + assert.equal(fx.text(fx.finde(sie.form, '.ab-notiz')), 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.') + sie.form.remove() + const du = monte(fx, ab(), { anrede: ANREDE_DU, antwort: async () => ({ ok: false, status: 0, body: {} }) }) + assert.equal(fx.text(fx.finde(du.form, 'legend')), 'Deine Bestätigung') + assert.equal(fx.text(fx.finde(du.form, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.') + await fx.klick(du.haken1) + await fx.klick(du.knopf) + assert.equal(du.fehler.textContent, 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.') + const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) + assert.deepEqual(fx.alle(renderBestaetigtBlock(wunsch), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.deepEqual(fx.alle(renderBestaetigtBlock(wunsch, { anrede: ANREDE_DU }), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.']) +})) + +test('Quelltext: nur Text, nur ./auftrag-ansicht.js, kein fetch, keine Kundentexte im Modul', () => { + pruefeQuelltext('auftrag-dom.js', { importe: ['./auftrag-ansicht.js'] }) +}) From 38734e99aaa262ddfbbff0272609e9e8e01345d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:59 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20Seitenlogik?= =?UTF-8?q?=20der=20=C3=B6ffentlichen=20Best=C3=A4tigungsseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/auftrag.js | 107 ++++++++++++++++ test/auftraegeKundenportalSeite.test.js | 160 ++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 public/auftrag.js create mode 100644 test/auftraegeKundenportalSeite.test.js diff --git a/public/auftrag.js b/public/auftrag.js new file mode 100644 index 0000000..4207b9a --- /dev/null +++ b/public/auftrag.js @@ -0,0 +1,107 @@ +/*! WEBklar Aufträge: öffentliche Bestätigungsseite (auftrag.html#t=, Rückfall ?t=). Link aus der Mail K31. + * Der Token steht nur in der Adresse, in Links und im Speicher der Seite, nie in Text, Ansage, Fehlermeldung oder Protokoll. + * Keine Cookies (credentials: 'omit'), kein Browserspeicher, keine Drittanfragen. Alle Texte stehen in auftrag-ansicht.js (Sie-Form) + * und kommen nur über textContent in die Seite. */ +import { + KONTAKT, agbPfad, darfBestaetigen, pdfPfad, statusAnzeige, texte, tokenAusAdresse, zustandAusAntwort, +} from './auftrag-ansicht.js' +import { h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung } from './auftrag-dom.js' + +/** Einzige Anfrage der Seite: { status, body }; jeder Fehler des Netzes ist Status 0 (nie die Meldung des Browsers weiterreichen). */ +async function anfrage(token, aktion, { method = 'GET', body } = {}) { + try { + const antwort = await fetch(`/api/auftrag/${encodeURIComponent(token)}${aktion ? `/${aktion}` : ''}`, { + method, + credentials: 'omit', + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + const daten = await antwort.json().catch(() => ({})) + return { status: antwort.status, body: daten !== null && typeof daten === 'object' ? daten : {} } + } catch { + return { status: 0, body: {} } + } +} + +export async function initAuftragsseite({ root, ansage, suche = '', hash = '' }) { + const t = texte() + const token = tokenAusAdresse(suche, hash) + + const sage = (text) => { ansage.textContent = text } + const zeige = (zustand, ...kinder) => { + root.dataset.zustand = zustand + root.setAttribute('aria-busy', zustand === 'laedt' ? 'true' : 'false') + root.replaceChildren(...kinder.filter(Boolean)) + } + const kontakt = () => h('p', { class: 'ab-kontakt' }, + t.kontaktVor, ' ', + h('a', { href: `mailto:${KONTAKT.email}` }, KONTAKT.email), ' oder ', + h('a', { href: `tel:${KONTAKT.telefon.replace(/\D/g, '')}` }, KONTAKT.telefon), '.') + + function zeigeZustand({ zustand, titel, text }) { + zeige(zustand, + h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, titel), + h('p', { class: 'ab-text' }, text), + zustand === 'limit' ? null : kontakt(), + zustand === 'limit' || zustand === 'fehler' ? h('button', { type: 'button', class: 'ab-knopf', 'data-aktion': 'erneut', onclick: () => lade() }, t.erneut) : null) + sage(titel) + } + + async function senden(koerper) { + const { status, body } = await anfrage(token, 'bestaetigen', { method: 'POST', body: koerper }) + if (status === 200 && body.auftrag) return { ok: true, auftrag: body.auftrag } + return { ok: false, status: status === 200 ? 0 : status, body } + } + + function zeigeAuftrag(auftrag) { + const bestaetigt = Boolean(auftrag.bestaetigtAm) + const offen = darfBestaetigen(auftrag) + const status = statusAnzeige({ ...auftrag, kannBestaetigen: offen }) + zeige(bestaetigt ? 'bestaetigt' : 'offen', + h('p', { class: `ab-pille ab-pille-${status.ton}` }, status.text), + h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, bestaetigt ? t.titelBestaetigt : `${t.titelOffen} ${auftrag.nummer || ''}`.trim()), + bestaetigt ? null : h('p', { class: 'ab-intro' }, t.intro), + renderBestaetigtBlock(auftrag), + renderZusammenfassung(auftrag), + h('section', { class: 'ab-abschnitt' }, + h('h2', { class: 'ab-h2' }, t.unterlagen), + renderDokumente({ pdfHref: pdfPfad(token), agbHref: agbPfad(auftrag.agbUrl) })), + offen + ? renderBestaetigung(auftrag, { + senden, + nachErfolg: (neu) => { + zeigeAuftrag(neu) + sage(t.titelBestaetigt) + root.querySelector('[data-bestaetigt]')?.focus() + }, + nachEnde: (code, antwortBody) => zeigeZustand(zustandAusAntwort(code, antwortBody)), + }) + : null) + } + + async function lade() { + if (!token) { + zeigeZustand(zustandAusAntwort(404, {})) + return + } + zeige('laedt', h('p', { class: 'ab-laden' }, t.laedt)) + const { status, body } = await anfrage(token, '') + if (status === 200 && body.auftrag) { + zeigeAuftrag(body.auftrag) + sage(t.geladen) + } else { + zeigeZustand(zustandAusAntwort(status === 200 ? 0 : status, body)) + } + } + + await lade() +} + +if (typeof document !== 'undefined' && document.getElementById('ab-wurzel')) { + initAuftragsseite({ + root: document.getElementById('ab-wurzel'), + ansage: document.getElementById('ab-ansage'), + suche: window.location.search, + hash: window.location.hash, + }) +} diff --git a/test/auftraegeKundenportalSeite.test.js b/test/auftraegeKundenportalSeite.test.js new file mode 100644 index 0000000..7ac8c5b --- /dev/null +++ b/test/auftraegeKundenportalSeite.test.js @@ -0,0 +1,160 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initAuftragsseite } from '../public/auftrag.js' +import { TOKEN, KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const ok = (body, status = 200) => ({ status, body }) +const mit = (antworten, fn) => async () => { + const fx = installFakeDom({ antworten }) + const root = h('div', { id: 'ab-wurzel' }) + const ansage = h('div', { id: 'ab-ansage' }) + fx.document.body.append(root, ansage) + try { + await fn(fx, root, ansage) + } finally { + fx.restore() + } +} +const start = (root, ansage, adresse = {}) => initAuftragsseite({ root, ansage, suche: '', hash: `#t=${TOKEN}`, ...adresse }) // K31 verlinkt #t= +const bestaetige = async (fx, root, { frueh = false } = {}) => { + await fx.klick(fx.finde(root, '[data-feld="bestaetigung"]')) + if (frueh) await fx.klick(fx.finde(root, '[data-feld="fruehbeginn"]')) + await fx.klick(fx.finde(root, '[data-aktion="bestaetigen"]')) +} + +test('Ohne gültigen Token: „ungültig“ und Kontaktweg, ohne Anfrage', mit([], async (fx, root, ansage) => { + for (const adresse of [{ hash: '' }, { hash: '#t=' }, { hash: '#t=zu-kurz' }, { hash: `#t=${TOKEN}x` }, { hash: '', suche: '?t=zu-kurz' }]) { + await start(root, ansage, adresse) + assert.equal(root.dataset.zustand, 'ungueltig', JSON.stringify(adresse)) + assert.equal(fx.finde(root, 'h1').textContent, 'Dieser Link ist ungültig oder abgelaufen.') + assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]').textContent, 'support@webklar.com') + assert.equal(fx.finde(root, 'a[href="tel:017623726355"]').textContent, '0176 23726355') + } + assert.equal(fx.fetchLog.length, 0) +})) + +test('Laden und Anzeigen: Kopf, Zusammenfassung, Unterlagen, Formular; der Token steht nur in Links', mit([ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.deepEqual(fx.fetchLog, [{ method: 'GET', url: `/api/auftrag/${TOKEN}`, body: undefined }]) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.finde(root, 'h1').textContent, 'Auftragsbestätigung AB-2026-0002') + assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bitte bestätigen') + assert.equal(fx.alle(root, '[data-position]').length, 2) + const pdf = fx.finde(root, '[data-link="pdf"]') + const agb = fx.finde(root, '[data-link="agb"]') + assert.equal(pdf.getAttribute('href'), `/api/auftrag/${TOKEN}/pdf`) + assert.equal(agb.getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf') + assert.ok(fx.finde(root, 'form [data-feld="bestaetigung"]')) + assert.equal(fx.finde(root, '[data-feld="fruehbeginn"]'), null) + assert.equal(fx.finde(root, '[data-hinweis]').textContent, KLEINUNTERNEHMER, 'der § 19-Satz kommt aus der API') + assert.equal(fx.finde(root, 'legend').textContent, 'Ihre Bestätigung', 'öffentliche Seite: Sie-Form') + assert.equal(fx.text(ansage), 'Ihre Auftragsbestätigung wurde geladen.') + assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN)) +})) + +test('Der Token steht im Fragment (#t=, so verlinkt K31); als Rückfall genügt ?t=', mit([ok({ auftrag: ab() }), ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'offen') + await start(root, ansage, { hash: '', suche: `?t=${TOKEN}` }) + assert.equal(root.dataset.zustand, 'offen') + assert.deepEqual(fx.fetchLog.map((a) => a.url), [`/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}`]) +})) + +test('Aufwand: Aufwandstext statt Tabelle', mit([ok({ auftrag: ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde.' }) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(fx.finde(root, '[data-aufwand]').textContent, '100,00 € pro Stunde.') + assert.equal(fx.finde(root, 'table'), null) +})) + +test('Bestätigen (Unternehmen): POST mit Häkchen, danach „bestätigt“ mit Zeitpunkt, Fokus und Ansage', mit([ok({ auftrag: ab() }), ok({ auftrag: bestaetigt(ab()) })], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.deepEqual(fx.fetchLog[1], { method: 'POST', url: `/api/auftrag/${TOKEN}/bestaetigen`, body: { bestaetigt: true, fruehbeginn: false } }) + assert.equal(root.dataset.zustand, 'bestaetigt') + assert.equal(fx.finde(root, 'h1').textContent, 'Ihr Auftrag ist bestätigt.') + assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bestätigt') + const block = fx.finde(root, '[data-bestaetigt]') + assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr']) + assert.equal(fx.finde(root, 'form'), null) + assert.equal(fx.document.activeElement, block) + assert.equal(fx.text(ansage), 'Ihr Auftrag ist bestätigt.') + assert.ok(fx.finde(root, '[data-link="pdf"]'), 'Unterlagen bleiben erreichbar') +})) + +test('Privatperson: zweites Häkchen; angezeigt wird, was der Server meldet (Frühbeginn „nein“ trotz Häkchen)', mit([ok({ auftrag: privat() }), ok({ auftrag: bestaetigt(privat({ fruehbeginn: false })) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.ok(fx.finde(root, '[data-feld="fruehbeginn"]')) + await bestaetige(fx, root, { frueh: true }) + assert.deepEqual(fx.fetchLog[1].body, { bestaetigt: true, fruehbeginn: true }) + assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [ + 'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.', + ]) +})) + +test('Schon bestätigt beim Laden: „bestätigt“ ohne Formular, mit der Zeile zum Beginn', mit([ok({ auftrag: bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'bestaetigt') + assert.equal(fx.finde(root, 'form'), null) + assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [ + 'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.', + ]) +})) + +test('Zustände: ungültig, ersetzt, storniert, widerrufen, zu viele Anfragen, Serverfehler – feste Texte, nie der Token', mit([], async (fx, root, ansage) => { + const faelle = [ + [ok({ error: 'Dieser Link ist ungültig oder abgelaufen.' }, 404), 'ungueltig', 'Dieser Link ist ungültig oder abgelaufen.'], + [ok({ error: 'Diese Auftragsbestätigung wurde ersetzt.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde ersetzt.'], + [ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde storniert.'], + [ok({ error: 'Diese Auftragsbestätigung wurde widerrufen.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde widerrufen.'], + [ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429), 'limit', 'Zu viele Anfragen'], + [ok({ error: 'Interner Fehler.' }, 500), 'fehler', 'Das hat leider nicht geklappt'], + [ok({}, 502), 'fehler', 'Das hat leider nicht geklappt'], + [ok({}, 200), 'fehler', 'Das hat leider nicht geklappt'], // Antwort ohne auftrag + ] + for (const [antwort, zustand, titel] of faelle) { + fx.antworten.push(antwort) + await start(root, ansage) + assert.equal(root.dataset.zustand, zustand, titel) + assert.equal(fx.finde(root, 'h1').textContent, titel) + assert.equal(fx.text(ansage), titel) + assert.equal(fx.finde(root, 'form'), null) + assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN)) + assert.equal(Boolean(fx.finde(root, '[data-aktion="erneut"]')), ['limit', 'fehler'].includes(zustand), `Erneut-Knopf bei ${zustand}`) + } + assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]') !== null, true, 'auch beim Fehler steht der Kontaktweg da') +})) + +test('Netzfehler beim Laden: Fehlerzustand ohne Fremdtext, „Erneut versuchen“ lädt neu', mit([() => { throw new TypeError('fetch failed') }, ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'fehler') + assert.ok(!fx.text(root).includes('fetch failed')) + await fx.klick(fx.finde(root, '[data-aktion="erneut"]')) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.fetchLog.length, 2) +})) + +test('Status wechselt zwischen Laden und Bestätigen (410): „storniert“, kein Formular mehr', mit([ok({ auftrag: ab() }), ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410)], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.equal(root.dataset.zustand, 'beendet') + assert.equal(fx.finde(root, 'h1').textContent, 'Diese Auftragsbestätigung wurde storniert.') + assert.equal(fx.finde(root, 'form'), null) +})) + +test('Fehler beim Bestätigen (429): Meldung in der Fehlerzeile, Seite bleibt offen, Häkchen bleiben', mit([ok({ auftrag: ab() }), ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429)], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.finde(root, '[data-fehler]').textContent, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.') + assert.equal(fx.finde(root, '[data-feld="bestaetigung"]').checked, true) + assert.equal(fx.finde(root, '[data-aktion="bestaetigen"]').disabled, false) + assert.ok(!fx.text(root).includes(TOKEN)) +})) + +test('Quelltext: genau ein fetch ohne Cookies, keine Protokollausgabe, nur relative Importe', () => { + const quelle = pruefeQuelltext('auftrag.js', { fetchAnzahl: 1, importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + assert.match(quelle, /credentials: 'omit'/) + assert.doesNotMatch(quelle, /location\.href|history\.|document\.referrer/) +}) From cad9ba7e40a6e453313fa5e0a07f63acebebf83a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:14:45 +0000 Subject: [PATCH 5/9] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20auftrag.htm?= =?UTF-8?q?l=20und=20auftrag.css=20(noindex,=20Mail-Stil,=20Fu=C3=9Fzeile,?= =?UTF-8?q?=20Standalone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/auftrag.css | 132 +++++++++++++++++++++++ public/auftrag.html | 42 ++++++++ test/auftraegeKundenportalHuelle.test.js | 52 +++++++++ 3 files changed, 226 insertions(+) create mode 100644 public/auftrag.css create mode 100644 public/auftrag.html create mode 100644 test/auftraegeKundenportalHuelle.test.js diff --git a/public/auftrag.css b/public/auftrag.css new file mode 100644 index 0000000..dee3b6e --- /dev/null +++ b/public/auftrag.css @@ -0,0 +1,132 @@ +/* Bestätigungsseite für Auftragsbestätigungen (auftrag.html) und Abschnitt „Aufträge“ im Portal; Farben wie die Mailvorlagen. + Die Datei wird auch vom Dashboard geladen: jede Regel ist an body.ab-seite bzw. .ab-portal gebunden, Ausnahmen sind + .ab-rechnungen-titel und .inv-project am Ende. */ + +/* ------------------------------------------------------------------ Farben --- */ +body.ab-seite { + --ab-grund: #f1f0ec; --ab-karte: #ffffff; --ab-text: #111111; --ab-weich: #4a4843; --ab-linie: #e8e6e0; --ab-box: #f7f6f3; + --ab-knopf: #111111; --ab-knopf-text: #ffffff; --ab-fokus: #5a2a91; + --ab-warn-bg: #efe6fb; --ab-warn-text: #5a2a91; --ab-ok-bg: #e4f2e6; --ab-ok-text: #1d5b2c; + --ab-rot-bg: #fbe4e1; --ab-rot-text: #8c1d18; --ab-muted-bg: #ecebe6; --ab-muted-text: #4a4843; --ab-fehler: #b3261e; + color-scheme: light dark; +} +@media (prefers-color-scheme: dark) { + body.ab-seite { + --ab-grund: #161615; --ab-karte: #1f1f1e; --ab-text: #f2f1ee; --ab-weich: #a8a6a0; --ab-linie: #3a3a37; --ab-box: #2a2a28; + --ab-knopf: #f2f1ee; --ab-knopf-text: #161615; --ab-fokus: #d9c2f5; + --ab-warn-bg: #2d2140; --ab-warn-text: #d9c2f5; --ab-ok-bg: #1e3322; --ab-ok-text: #a9dcb3; + --ab-rot-bg: #3d1f1c; --ab-rot-text: #f4b5ae; --ab-muted-bg: #2d2d2b; --ab-muted-text: #a8a6a0; --ab-fehler: #ff8a80; + } +} + +/* ------------------------------------------------------------------ Grundlayout --- */ +body.ab-seite { + margin: 0; min-height: 100dvh; display: flex; flex-direction: column; + background: var(--ab-grund); color: var(--ab-text); + font: 16px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-text-size-adjust: 100%; +} +.ab-seite .hidden { display: none !important; } +.ab-seite .ab-innen { max-width: 40rem; margin: 0 auto; padding: 0 16px; box-sizing: border-box; width: 100%; } +.ab-seite .ab-kopf { padding: max(20px, env(safe-area-inset-top)) 0 12px; } +.ab-seite .ab-kopf .ab-innen { display: flex; align-items: center; gap: 10px; } +.ab-seite .ab-logo { width: 36px; height: 36px; border-radius: 10px; display: block; } +.ab-seite .ab-marke { font-weight: 800; letter-spacing: 0.08em; font-size: 15px; } +.ab-seite .ab-haupt { flex: 1 0 auto; padding-bottom: 24px; } +.ab-seite .ab-wurzel { + background: var(--ab-karte); border: 1px solid var(--ab-linie); border-radius: 16px; padding: 28px 24px; + overflow-wrap: anywhere; word-break: normal; +} +.ab-seite .ab-laden { margin: 0; color: var(--ab-weich); } +.ab-seite a { color: inherit; text-underline-offset: 2px; } +.ab-seite .ab-vh { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} +.ab-seite .ab-fuss { flex-shrink: 0; padding: 8px 0 max(24px, env(safe-area-inset-bottom)); font-size: 13px; color: var(--ab-weich); } +.ab-seite .ab-fuss .ab-innen { display: flex; gap: 16px; flex-wrap: wrap; } +.ab-seite .ab-fuss a { color: var(--ab-weich); display: inline-block; min-height: 44px; line-height: 44px; } + +/* ------------------------------------------------------------------ Inhalt --- */ +.ab-seite .ab-pille, .ab-portal .ab-pille { + display: inline-block; margin: 0 0 12px; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; line-height: 1.4; + background: var(--ab-muted-bg); color: var(--ab-muted-text); +} +.ab-seite .ab-pille-warn, .ab-portal .ab-pille-warn { background: var(--ab-warn-bg); color: var(--ab-warn-text); } +.ab-seite .ab-pille-ok, .ab-portal .ab-pille-ok { background: var(--ab-ok-bg); color: var(--ab-ok-text); } +.ab-seite .ab-pille-rot, .ab-portal .ab-pille-rot { background: var(--ab-rot-bg); color: var(--ab-rot-text); } +.ab-seite .ab-h1 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 12px; } +.ab-seite .ab-h1:focus { outline: none; } +.ab-seite .ab-intro, .ab-seite .ab-text { margin: 0 0 16px; } +.ab-seite .ab-text, .ab-portal .ab-text { white-space: pre-line; } +.ab-seite .ab-kontakt { margin: 0 0 16px; color: var(--ab-weich); } +.ab-seite .ab-h2, .ab-portal .ab-h2 { + font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; color: var(--ab-weich); margin: 24px 0 8px; +} +.ab-seite .ab-liste, .ab-portal .ab-liste { margin: 0 0 8px; padding: 0; } +.ab-seite .ab-zeile, .ab-portal .ab-zeile { + display: grid; grid-template-columns: 9rem 1fr; gap: 4px 12px; padding: 8px 0; border-top: 1px solid var(--ab-linie); +} +.ab-seite .ab-zeile dt, .ab-portal .ab-zeile dt { color: var(--ab-weich); } +.ab-seite .ab-zeile dd, .ab-portal .ab-zeile dd { margin: 0; } +.ab-seite .ab-gesamt, .ab-portal .ab-gesamt { display: flex; justify-content: space-between; gap: 12px; margin: 12px 0 8px; padding-top: 12px; border-top: 2px solid var(--ab-text); } +.ab-seite .ab-gesamt strong, .ab-portal .ab-gesamt strong { font-weight: 800; } +.ab-seite .ab-hinweis, .ab-portal .ab-hinweis { margin: 4px 0 0; font-size: 0.85rem; color: var(--ab-weich); } +.ab-seite .ab-links, .ab-portal .ab-links { list-style: none; margin: 0 0 16px; padding: 0; } +.ab-seite .ab-links a, .ab-portal .ab-links a { display: inline-block; min-height: 44px; line-height: 44px; font-weight: 600; } +.ab-seite .ab-bestaetigt, .ab-portal .ab-bestaetigt { + background: var(--ab-ok-bg); color: var(--ab-ok-text); border-radius: 12px; padding: 12px 16px; margin: 0 0 16px; +} +.ab-seite .ab-bestaetigt p, .ab-portal .ab-bestaetigt p { margin: 2px 0; } +.ab-seite .ab-bestaetigt:focus-visible, .ab-portal .ab-bestaetigt:focus-visible { outline: 3px solid var(--ab-fokus); outline-offset: 2px; } + +/* ------------------------------------------------------------------ Tabelle --- */ +.ab-seite .ab-tabelle, .ab-portal .ab-tabelle { width: 100%; border-collapse: collapse; font-size: 0.95rem; } +.ab-seite .ab-tabelle th, .ab-portal .ab-tabelle th { text-align: left; font-weight: 600; color: var(--ab-weich); padding: 6px 8px 6px 0; border-bottom: 1px solid var(--ab-linie); } +.ab-seite .ab-tabelle td, .ab-portal .ab-tabelle td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--ab-linie); vertical-align: top; } +.ab-seite .ab-tabelle th:nth-child(n+2), .ab-seite .ab-tabelle td:nth-child(n+2), +.ab-portal .ab-tabelle th:nth-child(n+2), .ab-portal .ab-tabelle td:nth-child(n+2) { text-align: right; white-space: nowrap; } +@media (max-width: 640px) { + .ab-seite .ab-tabelle thead, .ab-portal .ab-tabelle thead { + position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; + } + .ab-seite .ab-tabelle, .ab-seite .ab-tabelle tbody, .ab-seite .ab-tabelle tr, .ab-seite .ab-tabelle td, + .ab-portal .ab-tabelle, .ab-portal .ab-tabelle tbody, .ab-portal .ab-tabelle tr, .ab-portal .ab-tabelle td { display: block; } + .ab-seite .ab-tabelle tr, .ab-portal .ab-tabelle tr { padding: 8px 0; border-bottom: 1px solid var(--ab-linie); } + .ab-seite .ab-tabelle td, .ab-portal .ab-tabelle td { + display: flex; justify-content: space-between; gap: 12px; border: 0; padding: 2px 0; text-align: right; white-space: normal; + } + .ab-seite .ab-tabelle td:first-child, .ab-portal .ab-tabelle td:first-child { display: block; text-align: left; font-weight: 700; } + .ab-seite .ab-tabelle td:nth-child(n+2), .ab-portal .ab-tabelle td:nth-child(n+2) { white-space: normal; } + .ab-seite .ab-tabelle td:nth-child(n+2)::before, .ab-portal .ab-tabelle td:nth-child(n+2)::before { content: attr(data-label); color: var(--ab-weich); text-align: left; } + .ab-seite .ab-zeile, .ab-portal .ab-zeile { grid-template-columns: 1fr; gap: 0; } + .ab-seite .ab-wurzel { padding: 22px 18px; } +} + +/* ------------------------------------------------------------------ Formular --- */ +.ab-seite .ab-bestaetigen, .ab-portal .ab-bestaetigen { margin-top: 24px; padding-top: 8px; border-top: 1px solid var(--ab-linie); } +.ab-seite .ab-feldgruppe, .ab-portal .ab-feldgruppe { border: 0; margin: 0; padding: 0; min-width: 0; } +.ab-seite .ab-feldgruppe legend, .ab-portal .ab-feldgruppe legend { font-weight: 700; padding: 0; margin: 12px 0 4px; } +.ab-seite .ab-haken, .ab-portal .ab-haken { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; min-height: 44px; font-size: 15px; cursor: pointer; } +.ab-seite .ab-haken input, .ab-portal .ab-haken input { flex: 0 0 22px; width: 22px; height: 22px; margin: 1px 0 0; accent-color: var(--ab-knopf); } +.ab-seite .ab-frei, .ab-portal .ab-frei { color: var(--ab-weich); font-size: 0.85em; } +.ab-seite .ab-fehler, .ab-portal .ab-fehler { margin: 8px 0; color: var(--ab-fehler); font-weight: 700; } +.ab-seite .ab-fehler[hidden], .ab-portal .ab-fehler[hidden] { display: none; } +.ab-seite .ab-knopf, .ab-portal .ab-knopf { + display: inline-block; min-height: 48px; padding: 0 24px; border: 0; border-radius: 10px; cursor: pointer; + background: var(--ab-knopf); color: var(--ab-knopf-text); font: inherit; font-weight: 700; +} +.ab-seite .ab-knopf:disabled, .ab-portal .ab-knopf:disabled { opacity: 0.45; cursor: default; } +.ab-seite .ab-knopf:focus-visible, .ab-portal .ab-knopf:focus-visible, +.ab-seite .ab-link:focus-visible, .ab-portal .ab-link:focus-visible { outline: 3px solid var(--ab-fokus); outline-offset: 2px; } +.ab-seite .ab-notiz, .ab-portal .ab-notiz, .ab-seite .ab-gesperrt, .ab-portal .ab-gesperrt { margin: 12px 0 0; font-size: 0.85rem; color: var(--ab-weich); } +@media (max-width: 640px) { + .ab-seite .ab-knopf, .ab-portal .ab-knopf { width: 100%; } +} + +/* ------------------------------------------------------------------ Installierte App --- */ +.ab-app-back { display: none; } +@media (display-mode: standalone) { + .ab-app-back { + display: inline-block; margin: calc(env(safe-area-inset-top) + 12px) 16px 0; font-size: 15px; font-weight: 600; color: var(--ab-text); text-decoration: none; + } + .ab-seite .ab-kopf { padding-top: 12px; } +} diff --git a/public/auftrag.html b/public/auftrag.html new file mode 100644 index 0000000..7cbf4a4 --- /dev/null +++ b/public/auftrag.html @@ -0,0 +1,42 @@ + + + + + + + + + + + Auftragsbestätigung – WEBklar + + + + + + + + ‹ Zurück zum Kundenbereich +
+
WEBklar
+
+
+
+
+ +

Ihre Auftragsbestätigung wird geladen …

+
+
+
+
+ + + + diff --git a/test/auftraegeKundenportalHuelle.test.js b/test/auftraegeKundenportalHuelle.test.js new file mode 100644 index 0000000..7649c53 --- /dev/null +++ b/test/auftraegeKundenportalHuelle.test.js @@ -0,0 +1,52 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') +const html = lies('public/auftrag.html') +const css = lies('public/auftrag.css') + +test('auftrag.html: noindex, kein Referrer, Viewport mit Notch, Titel, ein Modul-Skript, kein Inline-Skript', () => { + assert.match(html, //) + assert.match(html, //) + assert.match(html, //) + assert.match(html, //) + assert.match(html, /Auftragsbestätigung – WEBklar<\/title>/) + assert.match(html, /<link rel="stylesheet" href="\/auftrag\.css">/) + assert.deepEqual(html.match(/<script\b[^>]*>/g), ['<script type="module" src="/auftrag.js">']) + assert.doesNotMatch(html, /<script>/) + assert.doesNotMatch(html, /\son[a-z]+=/i, 'keine Ereignis-Attribute') + assert.doesNotMatch(html, /https?:\/\/(?!webklar\.com\/impressum)/, 'keine Fremdressourcen') +}) + +test('auftrag.html: Wurzel im Zustand „lädt“, Live-Region, Hinweis ohne JavaScript', () => { + assert.match(html, /<div class="ab-wurzel" id="ab-wurzel" data-zustand="laedt" aria-busy="true">/) + assert.match(html, /<p class="ab-laden">Ihre Auftragsbestätigung wird geladen …<\/p>/) + assert.match(html, /<div class="ab-vh" id="ab-ansage" aria-live="polite"><\/div>/) + assert.match(html, /<noscript><p>Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen\.<\/p><\/noscript>/) + assert.match(html, /<noscript><style>\.ab-laden\{display:none\}<\/style><\/noscript>/) +}) + +test('auftrag.html: Fußzeile mit Datenschutz und Impressum; Zurück-Link nur für die installierte App', () => { + assert.match(html, /<footer class="ab-fuss">[\s\S]*<a href="\/datenschutz\.html" target="_blank" rel="noopener noreferrer">Datenschutz<\/a>[\s\S]*<a href="https:\/\/webklar\.com\/impressum" target="_blank" rel="noopener noreferrer">Impressum<\/a>[\s\S]*<\/footer>/) + assert.match(html, /<a class="ab-app-back" href="\/dashboard\.html">‹ Zurück zum Kundenbereich<\/a>/) +}) + +test('auftrag.css: Farben der Mailvorlagen hell und dunkel, Standalone-Modus, Handy-Tabelle, Umbruch, Tippflächen', () => { + for (const farbe of ['#f1f0ec', '#efe6fb', '#5a2a91', '#e4f2e6', '#1d5b2c', '#161615', '#1f1f1e', '#f2f1ee', '#a8a6a0']) assert.ok(css.includes(farbe), farbe) + assert.match(css, /@media \(prefers-color-scheme: dark\)/) + assert.match(css, /@media \(display-mode: standalone\)/) + assert.match(css, /env\(safe-area-inset-top\)/) + assert.match(css, /overflow-wrap:\s*anywhere/) + assert.match(css, /min-height:\s*48px/) + assert.match(css, /content:\s*attr\(data-label\)/) + assert.match(css, /\.ab-app-back\s*\{[^}]*display:\s*none/) + assert.match(css, /\.ab-vh\s*\{/) + assert.match(css, /body\.ab-seite[^{]*\.hidden|\.ab-seite \.hidden/) +}) + +test('Service Worker und Manifest brauchen für die neue Seite keinen Eintrag', () => { + assert.doesNotMatch(lies('public/sw.js'), /auftrag/) + const manifest = JSON.parse(lies('public/manifest.webmanifest')) + assert.equal(manifest.scope, '/', 'der Link aus der Mail kann in der installierten App öffnen: darum der Standalone-Modus oben') +}) From b53f9f67ed6302521ca2cb0b128d2a68554919cc Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Wed, 7 Oct 2026 14:15:29 +0000 Subject: [PATCH 6/9] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20Abschnitt?= =?UTF-8?q?=20=E2=80=9EAuftr=C3=A4ge=E2=80=9C=20im=20Portal=20(Karten,=20B?= =?UTF-8?q?est=C3=A4tigen,=20Ansichtsmodus=20gesperrt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- public/auftrag-portal.js | 84 ++++++++++++ public/auftrag.css | 29 +++++ test/auftraegeKundenportalPortal.test.js | 159 +++++++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 public/auftrag-portal.js create mode 100644 test/auftraegeKundenportalPortal.test.js diff --git a/public/auftrag-portal.js b/public/auftrag-portal.js new file mode 100644 index 0000000..6f8e711 --- /dev/null +++ b/public/auftrag-portal.js @@ -0,0 +1,84 @@ +/* Abschnitt „Aufträge“ im Reiter Rechnungen des Kundenportals (geladen von app.js, loadCustomerOrders; request ist api aus app.js). + Kundentext kommt nur über textContent in die Seite. Alle Texte stehen in auftrag-ansicht.js; das Portal spricht in der Du-Form. */ +import { + ANREDE_DU, agbPfad, darfBestaetigen, kartenMeta, sortiereAuftraege, statusAnzeige, texte, +} from './auftrag-ansicht.js' +import { + h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung, +} from './auftrag-dom.js' + +export async function initAuftraege({ section, body, invoicesTitle, request, viewAs = false } = {}) { + if (!section || !body || typeof request !== 'function') return { sichtbar: false } + const t = texte(ANREDE_DU) + let eintraege = [] + + /** Bestätigen darf, wer der Server dafür freigibt; in der Kundenansicht zeigt die Oberfläche die offene AB mit gesperrtem Formular. */ + const wirksam = (e) => darfBestaetigen(e) && (viewAs || e.kannBestaetigen === true) + + async function ladeListe() { + const daten = await request('/api/auftraege') + if (daten?.aktiv !== true || !Array.isArray(daten.auftraege)) return [] + return daten.auftraege.filter((e) => e && typeof e.id === 'string' && e.id !== '') + } + + function karte(auftrag, offeneId) { + const kann = wirksam(auftrag) + const status = statusAnzeige({ ...auftrag, kannBestaetigen: kann }) + const pfad = `/api/auftraege/${encodeURIComponent(auftrag.id)}` + + async function senden(koerper) { + try { + const antwort = await request(`${pfad}/bestaetigen`, { method: 'POST', body: JSON.stringify(koerper) }) + return antwort?.auftrag ? { ok: true, auftrag: antwort.auftrag } : { ok: false, status: 0, body: {} } + } catch (err) { + return { ok: false, status: Number(err?.status) || 0, body: { error: String(err?.message ?? '') } } + } + } + function nachErfolg(neu) { + eintraege = eintraege.map((alt) => (alt.id === auftrag.id ? { ...alt, ...neu, id: alt.id, kannBestaetigen: false } : alt)) + zeichne(auftrag.id) + body.querySelector(`[data-auftrag="${CSS.escape(auftrag.id)}"] [data-bestaetigt]`)?.focus() + } + async function nachEnde() { + try { + eintraege = await ladeListe() + zeichne('') + } catch { + /* die Anzeige bleibt stehen */ + } + } + + return h('article', { class: 'ab-karte', 'data-auftrag': auftrag.id, 'data-status': auftrag.status || '' }, + h('div', { class: 'ab-karte-kopf' }, + h('div', null, + h('p', { class: 'ab-karte-nummer' }, h('strong', null, auftrag.nummer || '')), + auftrag.titel ? h('p', { class: 'ab-karte-titel' }, auftrag.titel) : null, + h('p', { class: 'ab-karte-meta' }, kartenMeta(auftrag))), + h('span', { class: `ab-pille ab-pille-${status.ton}` }, status.text)), + h('div', { class: 'ab-karte-aktionen' }, + h('a', { class: 'ab-link', 'data-link': 'pdf', href: `${pfad}/pdf`, target: '_blank', rel: 'noopener noreferrer' }, t.kartePdf)), + h('details', { class: 'ab-details', open: kann || auftrag.id === offeneId }, + h('summary', null, kann ? t.karteDetailsBestaetigen : t.karteDetails), + renderZusammenfassung(auftrag, { ebene: 4 }), + renderDokumente({ agbHref: agbPfad(auftrag.agbUrl) }), + renderBestaetigtBlock(auftrag, { anrede: ANREDE_DU }), + kann + ? renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt: viewAs ? t.ansichtsmodus : '', anrede: ANREDE_DU }) + : null)) + } + + function zeichne(offeneId) { + body.replaceChildren(...sortiereAuftraege(eintraege.map((e) => ({ ...e, kannBestaetigen: wirksam(e) }))).map((e) => karte(eintraege.find((x) => x.id === e.id), offeneId))) + } + + try { + eintraege = await ladeListe() + } catch { + return { sichtbar: false } + } + if (!eintraege.length) return { sichtbar: false } + zeichne('') + section.classList.remove('hidden') + invoicesTitle?.classList.remove('hidden') + return { sichtbar: true } +} diff --git a/public/auftrag.css b/public/auftrag.css index dee3b6e..8463188 100644 --- a/public/auftrag.css +++ b/public/auftrag.css @@ -122,6 +122,35 @@ body.ab-seite { .ab-seite .ab-knopf, .ab-portal .ab-knopf { width: 100%; } } +/* ------------------------------------------------------------------ Portal (Reiter Rechnungen, dunkel) --- */ +.ab-portal { + --ab-karte: var(--kb-surface); --ab-text: #ffffff; --ab-weich: var(--kb-muted); --ab-linie: var(--kb-border); --ab-box: rgba(255, 255, 255, 0.04); + --ab-knopf: var(--kb-purple); --ab-knopf-text: #ffffff; --ab-fokus: #d4b4ff; + --ab-warn-bg: rgba(157, 61, 254, 0.16); --ab-warn-text: #d8b4fe; --ab-ok-bg: rgba(74, 222, 128, 0.1); --ab-ok-text: #86efac; + --ab-rot-bg: rgba(248, 113, 113, 0.12); --ab-rot-text: #fca5a5; --ab-muted-bg: rgba(255, 255, 255, 0.06); --ab-muted-text: var(--kb-muted); + --ab-fehler: #fca5a5; + margin: 0 0 28px; color: var(--ab-text); +} +.ab-portal h2 { font-size: 20px; margin: 0 0 6px; } +.ab-portal .ab-karte { + margin: 12px 0 0; padding: 16px 18px; border-radius: 16px; background: var(--ab-karte); border: 1px solid var(--ab-linie); overflow-wrap: anywhere; +} +.ab-portal .ab-karte-kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; } +.ab-portal .ab-karte-kopf .ab-pille { margin: 0; flex: none; } +.ab-portal .ab-karte-kopf p { margin: 0; } +.ab-portal .ab-karte-titel { margin-top: 2px; } +.ab-portal .ab-karte-meta { margin-top: 2px; font-size: 13px; color: var(--ab-weich); } +.ab-portal .ab-karte-aktionen { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } +.ab-portal .ab-karte-aktionen a { + display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); + color: var(--ab-text); text-decoration: none; font-weight: 600; font-size: 14px; +} +.ab-portal .ab-details > summary { cursor: pointer; display: flex; align-items: center; min-height: 44px; color: #d4b4ff; font-weight: 700; } +.ab-portal .ab-details[open] > summary { margin-bottom: 4px; } +.ab-portal .ab-knopf { border-radius: 999px; } +.ab-rechnungen-titel { font-size: 20px; margin: 0 0 10px; } +.inv-project { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--kb-muted, #6b6b70); overflow-wrap: anywhere; } + /* ------------------------------------------------------------------ Installierte App --- */ .ab-app-back { display: none; } @media (display-mode: standalone) { diff --git a/test/auftraegeKundenportalPortal.test.js b/test/auftraegeKundenportalPortal.test.js new file mode 100644 index 0000000..c480c29 --- /dev/null +++ b/test/auftraegeKundenportalPortal.test.js @@ -0,0 +1,159 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initAuftraege } from '../public/auftrag-portal.js' +import { ab, privat, bestaetigt, eintrag, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const NB = '\u{a0}' +const mit = (fn) => async () => { + const fx = installFakeDom() + try { + await fn(fx) + } finally { + fx.restore() + } +} +function aufbau(fx) { + const body = h('div', { id: 'orders-body' }) + const section = h('section', { id: 'orders-section', class: 'ab-portal hidden' }, body) + const invoicesTitle = h('h2', { id: 'invoices-title', class: 'hidden' }, 'Rechnungen') + fx.document.body.append(section, invoicesTitle) + return { section, body, invoicesTitle } +} +/** request wie api() in app.js: gibt die nächste Antwort zurück oder wirft sie (Error mit status). */ +function fakeRequest(antworten) { + const aufrufe = [] + const request = async (pfad, optionen = {}) => { + aufrufe.push({ pfad, methode: optionen.method || 'GET', body: optionen.body ? JSON.parse(optionen.body) : undefined }) + const antwort = antworten.shift() + if (antwort instanceof Error) throw antwort + return antwort + } + return { request, aufrufe } +} +const fehler = (status, text = `Fehler ${status}`) => Object.assign(new Error(text), { status }) +const sichtbar = (el) => !el.classList.contains('hidden') +const liste = (...auftraege) => ({ aktiv: true, auftraege }) + +const A_OFFEN = eintrag({ id: 'ab1', nummer: 'AB-2026-0002' }) +const A_OHNE_RECHT = eintrag({ id: 'ab4', nummer: 'AB-2026-0004', kannBestaetigen: false }) // Status offen, der Server erlaubt das Bestätigen nicht +const A_FERTIG = eintrag({ id: 'ab2', ...bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })), nummer: 'AB-2026-0003', kannBestaetigen: false }) +const A_STORNO = eintrag({ + id: 'ab0', nummer: 'AB-2026-0001', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, + aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', +}) +const bestaetige = async (fx, wurzel) => { + await fx.klick(fx.finde(wurzel, '[data-feld="bestaetigung"]')) + await fx.klick(fx.finde(wurzel, '[data-aktion="bestaetigen"]')) +} + +test('Der Abschnitt bleibt weg: Schalter aus, keine Aufträge, Ladefehler', mit(async (fx) => { + for (const antwort of [{ aktiv: false, auftraege: [] }, { aktiv: false, auftraege: [A_OFFEN] }, liste(), fehler(500), fehler(401)]) { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([antwort]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: false }) + assert.equal(sichtbar(els.section), false) + assert.equal(sichtbar(els.invoicesTitle), false) + assert.deepEqual(aufrufe, [{ pfad: '/api/auftraege', methode: 'GET', body: undefined }]) + els.section.remove() + els.invoicesTitle.remove() + } +})) + +test('Liste: Bestätigbares zuerst, Status, Zeile, PDF-Link; nur der offene Auftrag ist aufgeklappt und hat das Formular', mit(async (fx) => { + const els = aufbau(fx) + const { request } = fakeRequest([liste(A_STORNO, A_FERTIG, A_OHNE_RECHT, A_OFFEN)]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: true }) + assert.equal(sichtbar(els.section), true) + assert.equal(sichtbar(els.invoicesTitle), true) + const karten = fx.alle(els.body, '.ab-karte') + assert.deepEqual(karten.map((k) => k.getAttribute('data-auftrag')), ['ab1', 'ab4', 'ab2', 'ab0']) + const [offen, ohneRecht, fertig, storno] = karten + assert.equal(fx.finde(offen, '.ab-pille').textContent, 'Bitte bestätigen') + assert.equal(fx.finde(ohneRecht, '.ab-pille').textContent, 'Versendet') + assert.equal(fx.finde(ohneRecht, 'form'), null, 'ohne kannBestaetigen vom Server gibt es kein Formular') + assert.equal(fx.finde(fertig, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(storno, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(offen, '.ab-karte-titel').textContent, 'Website Anelia Wolf') + assert.equal(fx.finde(offen, '.ab-karte-meta').textContent, `vom 06.10.2026 · 800,00${NB}€`) + assert.equal(fx.finde(storno, '.ab-karte-meta').textContent, 'vom 06.10.2026 · nach Aufwand') + assert.equal(fx.finde(offen, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/ab1/pdf') + assert.equal(fx.finde(offen, '[data-link="pdf"]').textContent, 'PDF ansehen') + assert.equal(fx.finde(offen, 'summary').textContent, 'Details ansehen und bestätigen') + assert.equal(fx.finde(fertig, 'summary').textContent, 'Details ansehen') + assert.equal(fx.finde(offen, 'details').hasAttribute('open'), true) + assert.equal(fx.finde(fertig, 'details').hasAttribute('open'), false) + assert.ok(fx.finde(offen, 'form [data-feld="bestaetigung"]')) + assert.equal(fx.finde(fertig, 'form'), null) + assert.equal(fx.finde(storno, 'form'), null) + assert.deepEqual(fx.alle(fx.finde(fertig, '[data-bestaetigt]'), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.equal(fx.text(fx.finde(offen, 'legend')), 'Deine Bestätigung', 'Portal: Du-Form') + assert.equal(fx.text(fx.finde(offen, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.deepEqual(fx.alle(offen, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine']) + assert.equal(fx.finde(offen, '[data-link="agb"]').getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf') +})) + +test('Bestätigen: POST an /api/auftraege/<id>/bestaetigen, die Karte zeigt danach „Bestätigt“ (auch ohne id in der Antwort)', mit(async (fx) => { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), { auftrag: bestaetigt(ab()) }]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.deepEqual(aufrufe[1], { pfad: '/api/auftraege/ab1/bestaetigen', methode: 'POST', body: { bestaetigt: true, fruehbeginn: false } }) + const karte = fx.finde(els.body, '.ab-karte') + assert.equal(karte.getAttribute('data-auftrag'), 'ab1') + assert.equal(fx.finde(karte, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(karte, 'form'), null) + assert.equal(fx.finde(karte, 'details').hasAttribute('open'), true) + assert.equal(fx.document.activeElement, fx.finde(karte, '[data-bestaetigt]')) +})) + +test('Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar, kein POST; der Abschnitt bleibt lesbar', mit(async (fx) => { + const els = aufbau(fx) + // wie L5 es liefert: in der Kundenansicht ist kannBestaetigen immer false; die Oberfläche zeigt die offene AB trotzdem mit gesperrtem Formular + const { request, aufrufe } = fakeRequest([liste(eintrag({ ...privat(), id: 'ab1', kannBestaetigen: false }))]) + await initAuftraege({ ...els, request, viewAs: true }) + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Bitte bestätigen') + const form = fx.finde(els.body, 'form') + assert.equal(fx.alle(form, 'input').every((i) => i.disabled), true) + assert.equal(fx.finde(form, '[data-aktion="bestaetigen"]').disabled, true) + assert.equal(fx.finde(form, '[data-gesperrt]').textContent, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') + await fx.sende(form, 'submit') + assert.equal(aufrufe.length, 1, 'nur das Laden der Liste') + assert.ok(fx.finde(els.body, '[data-link="pdf"]')) + assert.ok(fx.finde(els.body, '[data-position]')) +})) + +test('Fehler beim Bestätigen: feste Meldung statt Servertext; ein 410 lädt die Liste neu', mit(async (fx) => { + const els = aufbau(fx) + const storniert = { ...A_OFFEN, status: 'storniert', kannBestaetigen: false } + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), fehler(500), fehler(410, 'Diese Auftragsbestätigung wurde storniert.'), liste(storniert)]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.equal(fx.finde(els.body, '[data-fehler]').textContent, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.') + assert.ok(!fx.text(els.body).includes('Fehler 500')) + await fx.klick(fx.finde(els.body, '[data-aktion="bestaetigen"]')) + assert.equal(aufrufe.length, 4, 'Liste, POST, POST, Liste neu') + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(els.body, 'form'), null) +})) + +test('Fremdtext bleibt Text; die ID im PDF-Link wird kodiert', mit(async (fx) => { + const els = aufbau(fx) + const boese = '<script>alert(1)</script> & "Test"' + const { request } = fakeRequest([liste(eintrag({ id: 'a/b', titel: boese }))]) + await initAuftraege({ ...els, request, viewAs: false }) + assert.equal(fx.alle(els.body, 'script').length, 0) + assert.equal(fx.finde(els.body, '.ab-karte-titel').textContent, boese) + assert.equal(fx.finde(els.body, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/a%2Fb/pdf') +})) + +test('Quelltext und CSS: kein eigenes fetch, Karten und Abschnitt sind gestaltet', () => { + pruefeQuelltext('auftrag-portal.js', { importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + const css = readFileSync(new URL('../public/auftrag.css', import.meta.url), 'utf8') + for (const klasse of ['ab-portal', 'ab-karte', 'ab-karte-kopf', 'ab-karte-titel', 'ab-karte-meta', 'ab-karte-aktionen', 'ab-details']) { + assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`) + } + assert.match(css, /\.ab-portal\s*\{[^}]*--ab-/, 'der Portal-Abschnitt setzt seine eigenen Farbschlüssel (dunkles Portal)') +}) From 73c5446cf878f380ef565f07cb8b2eecb599fa28 Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Wed, 7 Oct 2026 14:15:56 +0000 Subject: [PATCH 7/9] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20Abschnitt?= =?UTF-8?q?=20im=20Reiter=20Rechnungen,=20Projekt=20unter=20der=20Rechnung?= =?UTF-8?q?snummer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- public/app.js | 23 +++++++- public/auftrag.css | 2 +- public/dashboard.html | 9 +++ test/auftraegeKundenportalDashboard.test.js | 63 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 test/auftraegeKundenportalDashboard.test.js diff --git a/public/app.js b/public/app.js index b9251b8..e9d1938 100644 --- a/public/app.js +++ b/public/app.js @@ -584,6 +584,13 @@ function renderViewAsBanner(me) { } /* ------------------------------------------------------------- Rechnungen --- */ +// @rechnungsnummer-anfang +/** Rechnungsnummer mit dem Projekt darunter (Stripe-Metadaten projektName, Feld `projekt`); beides maskiert. */ +function invoiceNumberHtml(i) { + const project = i.projekt ? `<span class="inv-project">${escapeHtml(i.projekt)}</span>` : '' + return `${escapeHtml(i.number)}${project}` +} +// @rechnungsnummer-ende async function loadCustomerInvoices() { const body = document.getElementById('invoices-body') if (!body) return @@ -615,7 +622,7 @@ async function loadCustomerInvoices() { <tbody> ${invoices.map((i) => ` <tr class="${Number(i.balance) > 0 ? 'is-open' : 'is-paid'}"> - <td class="inv-no">${escapeHtml(i.number)}</td> + <td class="inv-no">${invoiceNumberHtml(i)}</td> <td class="inv-date">${fmtDateShort(i.date)}</td> <td class="inv-amount">${fmtEuro(i.amount)}</td> <td class="inv-open">${fmtEuro(i.balance)}</td> @@ -632,6 +639,19 @@ async function loadCustomerInvoices() { } } +// Abschnitt "Aufträge" im Reiter Rechnungen (public/auftrag-portal.js): Fehler lassen den Abschnitt weg und stören die Rechnungen nicht. +function loadCustomerOrders() { + return import('/auftrag-portal.js') + .then((m) => m.initAuftraege({ + section: document.getElementById('orders-section'), + body: document.getElementById('orders-body'), + invoicesTitle: document.getElementById('invoices-title'), + request: api, + viewAs: chatState.viewAs, + })) + .catch(() => {}) +} + /* -------------------------------------------------------------- Aktivität --- */ // Abschnitt "Was wir zuletzt erledigt haben" unter Projekte: je Auftrag eine Karte mit Status, Fortschrittsleiste // und Arbeiten (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server). Zuerst die drei neuesten, @@ -2063,6 +2083,7 @@ async function initDashboardPage() { if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`) loadContracts({ afterCheckout: aboParam === 'success' }) loadCustomerInvoices() + loadCustomerOrders() activityScrollPending = currentHash() === 'fortschritt' loadCustomerActivity().then(scrollToActivity) diff --git a/public/auftrag.css b/public/auftrag.css index 8463188..4899a59 100644 --- a/public/auftrag.css +++ b/public/auftrag.css @@ -149,7 +149,7 @@ body.ab-seite { .ab-portal .ab-details[open] > summary { margin-bottom: 4px; } .ab-portal .ab-knopf { border-radius: 999px; } .ab-rechnungen-titel { font-size: 20px; margin: 0 0 10px; } -.inv-project { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--kb-muted, #6b6b70); overflow-wrap: anywhere; } +.inv-project { display: block; margin-top: 2px; font-size: 12.5px; font-weight: 400; color: var(--kb-muted, rgba(255, 255, 255, 0.6)); overflow-wrap: anywhere; } /* ------------------------------------------------------------------ Installierte App --- */ .ab-app-back { display: none; } diff --git a/public/dashboard.html b/public/dashboard.html index 0401f1d..716e943 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -26,6 +26,7 @@ <link rel="stylesheet" href="/termine.css"> <link rel="stylesheet" href="/shop.css"> <link rel="stylesheet" href="/zeiten.css"> + <link rel="stylesheet" href="/auftrag.css"> </head> <body class="portal-page dashboard-page kb-page"> <div class="dot-background" aria-hidden="true"> @@ -222,6 +223,14 @@ <h1>Rechnungen</h1> <p>Deine Rechnungen ansehen, bezahlen und herunterladen.</p> </header> + <!-- Aufträge: Auftragsbestätigungen ansehen und bestätigen (auftrag-portal.js, von app.js loadCustomerOrders); erscheint nur, + wenn Aufträge freigeschaltet sind und der Kunde welche hat --> + <section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title"> + <h2 id="orders-title">Aufträge</h2> + <p class="kb-note">Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.</p> + <div id="orders-body"></div> + </section> + <h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen</h2> <div class="kb-panel" id="invoices-body"><p class="kb-empty">Lade…</p></div> <p class="kb-note hidden" id="invoices-abo-note">Die Zahlungsart für dein Abo änderst du über dein <button type="button" class="kb-link" data-open-account>Profilbild</button> oben rechts unter „Abo verwalten & Rechnungen“.</p> </section> diff --git a/test/auftraegeKundenportalDashboard.test.js b/test/auftraegeKundenportalDashboard.test.js new file mode 100644 index 0000000..1046812 --- /dev/null +++ b/test/auftraegeKundenportalDashboard.test.js @@ -0,0 +1,63 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') +const html = lies('public/dashboard.html') +const app = lies('public/app.js') +const css = lies('public/auftrag.css') + +test('dashboard.html: auftrag.css nach zeiten.css; Abschnitt „Aufträge“ vor der Rechnungsliste, zunächst versteckt', () => { + assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">\s*<link rel="stylesheet" href="\/auftrag\.css">/) + const start = html.indexOf('id="view-invoices"') + const ende = html.indexOf('</section>', html.indexOf('id="invoices-abo-note"')) + const ansicht = html.slice(start, ende) + assert.match(ansicht, /<section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title">\s*<h2 id="orders-title">Aufträge<\/h2>[\s\S]*<div id="orders-body"><\/div>\s*<\/section>\s*<h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen<\/h2>\s*<div class="kb-panel" id="invoices-body">/) + assert.match(ansicht, /<p class="kb-note">Deine Auftragsbestätigungen\. Offene Aufträge bestätigst du hier mit einem Klick\.<\/p>/, 'Portal: Du-Form') + assert.ok(ansicht.indexOf('id="orders-section"') < ansicht.indexOf('id="invoices-body"')) + assert.equal([...html.matchAll(/id="orders-section"/g)].length, 1) + assert.match(ansicht, /<h1>Rechnungen<\/h1>/, 'Kopf und Hinweis des Reiters bleiben') +}) + +test('app.js: loadCustomerOrders lädt auftrag-portal.js nach der Rechnungsliste, mit api und Ansichtsmodus; Fehler bleiben still', () => { + const funktion = app.slice(app.indexOf('function loadCustomerOrders()'), app.indexOf('/* ---', app.indexOf('function loadCustomerOrders()'))) + assert.ok(funktion.length > 40, 'Funktion gefunden') + assert.match(funktion, /import\('\/auftrag-portal\.js'\)/) + assert.match(funktion, /m\.initAuftraege\(/) + for (const teil of ["section: document.getElementById('orders-section')", "body: document.getElementById('orders-body')", "invoicesTitle: document.getElementById('invoices-title')", 'request: api', 'viewAs: chatState.viewAs']) { + assert.ok(funktion.includes(teil), teil) + } + assert.match(funktion, /\.catch\(\(\) => \{\}\)/) + const dashboard = app.slice(app.indexOf('async function initDashboardPage')) + const aufruf = (name) => dashboard.search(new RegExp(`^\\s*${name}\\(\\)\\s*$`, 'm')) + assert.ok(aufruf('loadCustomerInvoices') > 0 && aufruf('loadCustomerOrders') > aufruf('loadCustomerInvoices'), 'ein eigener Aufruf, nach der Rechnungsliste') + assert.equal([...app.matchAll(/function loadCustomerOrders\(\)/g)].length, 1) +}) + +test('invoiceNumberHtml: Projekt unter der Rechnungsnummer, alles maskiert', () => { + const ausschnitt = (muster) => { + const treffer = app.match(muster) + assert.ok(treffer, `${muster} nicht in app.js`) + return treffer[0] + } + const code = [ + ausschnitt(/function escapeHtml\(str\) \{[\s\S]*?\n\}/), + ausschnitt(/\/\/ @rechnungsnummer-anfang[\s\S]*?\/\/ @rechnungsnummer-ende/), + ].join('\n') + const { invoiceNumberHtml } = vm.runInNewContext(`${code}\n({ invoiceNumberHtml })`) + assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: 'Website Anelia Wolf' }), 'WEBKLAR-0009<span class="inv-project">Website Anelia Wolf</span>') + assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: '' }), 'WEBKLAR-0009') + assert.equal(invoiceNumberHtml({ number: 'H96T-0004' }), 'H96T-0004') + assert.equal( + invoiceNumberHtml({ number: 'X', projekt: '<img src=x onerror=alert(1)> & "Bäckerei"' }), + 'X<span class="inv-project"><img src=x onerror=alert(1)> & "Bäckerei"</span>', + ) + assert.match(app, /<td class="inv-no">\$\{invoiceNumberHtml\(i\)\}<\/td>/) + assert.doesNotMatch(app, /\$\{i\.projekt\}/, 'der Projektname geht nie unmaskiert in eine Vorlage') +}) + +test('auftrag.css: Projektname unter der Rechnungsnummer (Tabelle und Handy-Karte)', () => { + assert.match(css, /\.inv-project\s*\{[^}]*display:\s*block/) + assert.match(css, /\.inv-project\s*\{[^}]*overflow-wrap:\s*anywhere/) +}) From 60a51c0f68fac18340d63552980640a39020356a Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Wed, 7 Oct 2026 14:16:27 +0000 Subject: [PATCH 8/9] =?UTF-8?q?Widerrufsseite:=20Vertragsart=20Projektauft?= =?UTF-8?q?rag=20mit=20freiwilliger=20Nummer=20der=20Auftragsbest=C3=A4tig?= =?UTF-8?q?ung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- public/auftrag-widerruf.js | 56 +++++++++++ public/widerrufen.html | 10 +- test/auftraegeKundenportalWiderruf.test.js | 110 +++++++++++++++++++++ test/publicForms.test.js | 2 +- 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 public/auftrag-widerruf.js create mode 100644 test/auftraegeKundenportalWiderruf.test.js diff --git a/public/auftrag-widerruf.js b/public/auftrag-widerruf.js new file mode 100644 index 0000000..082c1ab --- /dev/null +++ b/public/auftrag-widerruf.js @@ -0,0 +1,56 @@ +/* Widerrufsseite: Vertragsart „Projektauftrag“ mit freiwilliger Nummer der Auftragsbestätigung (Feld abNummer). + Rechtsfunktion: dieses Modul darf die anderen Vertragsarten nie stören. Fällt es aus, funktioniert der Widerruf wie bisher. + Das Feld ist nur bei „Projektauftrag“ sichtbar; eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden, + ein leeres Feld lässt den Widerruf durch (der Widerruf bleibt formfrei). Die Meldung steht in auftrag-ansicht.js. */ +import { abNummerNormalisiert, texte } from './auftrag-ansicht.js' + +export function initProjektWiderruf(form) { + const wrap = form?.querySelector('#ab-nummer-wrap') + const feld = form?.querySelector('#abNummer') + const fehler = form?.querySelector('#form-error') + if (!form || !wrap || !feld || !fehler) return + const gewaehlt = () => form.querySelector('[name="order"]:checked')?.value + let eigeneMeldung = false + + const meldungWeg = () => { + if (!eigeneMeldung) return + eigeneMeldung = false + fehler.textContent = '' + fehler.classList.add('hidden') + } + const zeige = () => { + const projekt = gewaehlt() === 'projekt' + wrap.classList.toggle('hidden', !projekt) + if (!projekt) { + feld.value = '' + meldungWeg() + } + } + + form.addEventListener('change', zeige) + form.addEventListener('click', (ev) => { + if (!ev.target?.closest?.('button[type="submit"], input[type="submit"]') || gewaehlt() !== 'projekt') return + const eingabe = feld.value.trim() + if (!eingabe) { + feld.value = '' + meldungWeg() + return + } + const nummer = abNummerNormalisiert(eingabe) + if (!nummer) { + ev.preventDefault() + fehler.textContent = texte().abNummerUngueltig + fehler.classList.remove('hidden') + eigeneMeldung = true + feld.focus() + return + } + feld.value = nummer + meldungWeg() + }) + zeige() +} + +if (typeof document !== 'undefined' && document.getElementById('decl-form') && document.getElementById('ab-nummer-wrap')) { + initProjektWiderruf(document.getElementById('decl-form')) +} diff --git a/public/widerrufen.html b/public/widerrufen.html index 231320a..76fab22 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -24,8 +24,15 @@ <label class="radio"><input type="radio" name="order" value="termine" data-text="Terminplaner (Einrichtung)"> Terminplaner (Einrichtung)</label> <label class="radio"><input type="radio" name="order" value="shop" data-text="Webshop (Einrichtung)"> Webshop (Einrichtung)</label> <label class="radio"><input type="radio" name="order" value="zeiten" data-text="Öffnungszeiten live"> Öffnungszeiten live</label> + <label class="radio"><input type="radio" name="order" value="projekt" data-text="Projektauftrag (Auftragsbestätigung)"> Projektauftrag (Auftragsbestätigung)</label> </fieldset> + <!-- Nur bei „Projektauftrag“ und freiwillig (auftrag-widerruf.js blendet das Feld sonst aus und leert es); ohne das Skript bleibt es sichtbar --> + <div id="ab-nummer-wrap"> + <label for="abNummer" data-label-for="abNummer" data-label="Nummer der Auftragsbestätigung">Nummer der Auftragsbestätigung <small>(falls zur Hand, zum Beispiel AB-2026-0001)</small></label> + <input id="abNummer" name="abNummer" maxlength="20" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="AB-2026-0001"> + </div> + <label for="name" data-label-for="name" data-label="Name">Name</label> <input id="name" name="name" autocomplete="name" required maxlength="200"> @@ -49,7 +56,8 @@ </main> <script src="/legal-form.js"></script> <script> - initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['order', 'name', 'customerNumber', 'email'] }) + initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['order', 'abNummer', 'name', 'customerNumber', 'email'] }) </script> +<script type="module" src="/auftrag-widerruf.js"></script> </body> </html> diff --git a/test/auftraegeKundenportalWiderruf.test.js b/test/auftraegeKundenportalWiderruf.test.js new file mode 100644 index 0000000..0e18bcc --- /dev/null +++ b/test/auftraegeKundenportalWiderruf.test.js @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initProjektWiderruf } from '../public/auftrag-widerruf.js' +import { pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const html = readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8') +const MELDUNG = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.' + +test('widerrufen.html: Vertragsart „Projektauftrag“ hinter „Öffnungszeiten live“, freiwilliges Feld abNummer vor dem Namen, Skripte', () => { + assert.match(html, /name="order" value="zeiten"[^>]*> Öffnungszeiten live<\/label>\s*<label class="radio"><input type="radio" name="order" value="projekt" data-text="Projektauftrag \(Auftragsbestätigung\)"> Projektauftrag \(Auftragsbestätigung\)<\/label>\s*<\/fieldset>/) + assert.match(html, /<div id="ab-nummer-wrap">\s*<label for="abNummer" data-label-for="abNummer" data-label="Nummer der Auftragsbestätigung">Nummer der Auftragsbestätigung <small>\(falls zur Hand, zum Beispiel AB-2026-0001\)<\/small><\/label>\s*<input id="abNummer" name="abNummer" maxlength="20" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="AB-2026-0001">\s*<\/div>/) + assert.doesNotMatch(html, /<input id="abNummer"[^>]*required/, 'die Nummer ist freiwillig') + assert.ok(html.indexOf('id="ab-nummer-wrap"') < html.indexOf('for="name"'), 'das Feld steht vor dem Namen') + assert.doesNotMatch(html, /id="ab-nummer-wrap"[^>]*hidden/, 'ohne das Skript bleibt das Feld sichtbar') + assert.match(html, /initLegalForm\(\{ endpoint: '\/api\/abo\/public\/withdraw', fields: \['order', 'abNummer', 'name', 'customerNumber', 'email'\] \}\)/) + assert.match(html, /<script type="module" src="\/auftrag-widerruf\.js"><\/script>/) + for (const wert of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']) assert.match(html, new RegExp(`name="order" value="${wert}"`), `${wert} bleibt`) +}) + +const mit = (fn) => async () => { + const fx = installFakeDom() + try { + await fn(fx) + } finally { + fx.restore() + } +} +function aufbau(fx, order = 'abschluss') { + const radios = ['abschluss', 'projekt'].map((wert) => h('input', { type: 'radio', name: 'order', value: wert, checked: wert === order })) + const nummer = h('input', { id: 'abNummer', name: 'abNummer' }) + const wrap = h('div', { id: 'ab-nummer-wrap' }, h('label', { for: 'abNummer' }, 'Nummer'), nummer) + const fehler = h('p', { id: 'form-error', class: 'error hidden' }) + const knopf = h('button', { type: 'submit' }, 'Widerruf bestätigen') + const form = h('form', { id: 'decl-form' }, h('fieldset', {}, ...radios.map((r) => h('label', { class: 'radio' }, r))), wrap, fehler, knopf) + fx.document.body.append(form) + const gesendet = [] + form.addEventListener('submit', (ev) => { + ev.preventDefault() + gesendet.push(nummer.value) + }) + initProjektWiderruf(form) + return { form, radios, nummer, wrap, fehler, knopf, gesendet } +} + +test('Das Feld erscheint nur bei „Projektauftrag“ und wird beim Wegwählen geleert', mit(async (fx) => { + const t = aufbau(fx, 'abschluss') + assert.equal(t.wrap.classList.contains('hidden'), true) + await fx.klick(t.radios[1]) + assert.equal(t.wrap.classList.contains('hidden'), false) + await fx.setze(t.nummer, 'AB-2026-0001') + await fx.klick(t.radios[0]) + assert.equal(t.wrap.classList.contains('hidden'), true) + assert.equal(t.nummer.value, '') +})) + +test('Ist „Projektauftrag“ schon gewählt, ist das Feld sofort da', mit(async (fx) => { + const t = aufbau(fx, 'projekt') + assert.equal(t.wrap.classList.contains('hidden'), false) +})) + +test('Projektauftrag: eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden; Wechsel der Vertragsart nimmt die Meldung weg', mit(async (fx) => { + const t = aufbau(fx, 'projekt') + for (const eingabe of ['AB-2026-01', 'RE-2026-0001', 'irgendwas']) { + await fx.setze(t.nummer, eingabe) + await fx.klick(t.knopf) + assert.deepEqual(t.gesendet, [], eingabe) + assert.equal(t.fehler.classList.contains('hidden'), false) + assert.equal(t.fehler.textContent, MELDUNG) + assert.equal(t.nummer.value, eingabe) + } + assert.equal(fx.document.activeElement, t.nummer) + await fx.klick(t.radios[0]) + assert.equal(t.fehler.classList.contains('hidden'), true) +})) + +test('Ohne Nummer geht der Projektauftrag durch (freiwilliges Feld); die eigene Meldung verschwindet, sobald der Widerruf durchgeht', mit(async (fx) => { + const t = aufbau(fx, 'projekt') + await fx.klick(t.knopf) + assert.deepEqual(t.gesendet, ['']) + assert.equal(t.fehler.classList.contains('hidden'), true) + await fx.setze(t.nummer, 'kaputt') + await fx.klick(t.knopf) + assert.deepEqual(t.gesendet, [''], 'unpassende Eingabe: nichts gesendet') + assert.equal(t.fehler.classList.contains('hidden'), false) + await fx.setze(t.nummer, ' ') + await fx.klick(t.knopf) + assert.deepEqual(t.gesendet, ['', ''], 'nur Leerzeichen zählen als leer') + assert.equal(t.fehler.classList.contains('hidden'), true) +})) + +test('Gültige Nummer in anderer Schreibweise wird vereinheitlicht, dann geht der Widerruf durch', mit(async (fx) => { + const t = aufbau(fx, 'projekt') + await fx.setze(t.nummer, ' ab 2026 0001 ') + await fx.klick(t.knopf) + assert.deepEqual(t.gesendet, ['AB-2026-0001']) + assert.equal(t.fehler.classList.contains('hidden'), true) +})) + +test('Andere Vertragsart: kein Zwang, das Feld wird leer gesendet', mit(async (fx) => { + const t = aufbau(fx, 'abschluss') + await fx.klick(t.knopf) + assert.deepEqual(t.gesendet, ['']) +})) + +test('Quelltext: nur ./auftrag-ansicht.js, kein fetch', () => { + pruefeQuelltext('auftrag-widerruf.js', { importe: ['./auftrag-ansicht.js'] }) +}) diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 924717e..a87e953 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -1456,7 +1456,7 @@ test('widerrufen_html_radio: Radioknöpfe order', () => { assert.match(src, /name="order" value="shop"[^>]*data-text="Webshop \(Einrichtung\)"[^>]*> Webshop \(Einrichtung\)<\/label>/) assert.match(src, /name="order" value="zeiten"[^>]*data-text="Öffnungszeiten live"[^>]*> Öffnungszeiten live<\/label>/) assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) - assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/) + assert.match(src, /fields: \['order', 'abNummer', 'name', 'customerNumber', 'email'\]/) }) test('legal-form.js: Auswahl statt festem Vertrag, packages nur beim Zusatzspeicher', () => { From d990155df931c9380e99054bb19b87448b647980 Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Wed, 7 Oct 2026 14:18:23 +0000 Subject: [PATCH 9/9] =?UTF-8?q?Kundenportal=20Auftr=C3=A4ge:=20Playwright-?= =?UTF-8?q?Pr=C3=BCfung=20mit=20Bildschirmfotos=20(Handy=20390=20px,=20Des?= =?UTF-8?q?ktop=201440=20px,=20dunkel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- public/auftrag.css | 4 +- test/ui/auftraege-kundenportal.ui.mjs | 286 ++++++++++++++++++++++++++ 2 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 test/ui/auftraege-kundenportal.ui.mjs diff --git a/public/auftrag.css b/public/auftrag.css index 4899a59..4b1e49b 100644 --- a/public/auftrag.css +++ b/public/auftrag.css @@ -43,7 +43,7 @@ body.ab-seite { } .ab-seite .ab-fuss { flex-shrink: 0; padding: 8px 0 max(24px, env(safe-area-inset-bottom)); font-size: 13px; color: var(--ab-weich); } .ab-seite .ab-fuss .ab-innen { display: flex; gap: 16px; flex-wrap: wrap; } -.ab-seite .ab-fuss a { color: var(--ab-weich); display: inline-block; min-height: 44px; line-height: 44px; } +.ab-seite .ab-fuss a { color: var(--ab-weich); display: inline-flex; align-items: center; min-height: 44px; } /* ------------------------------------------------------------------ Inhalt --- */ .ab-seite .ab-pille, .ab-portal .ab-pille { @@ -71,7 +71,7 @@ body.ab-seite { .ab-seite .ab-gesamt strong, .ab-portal .ab-gesamt strong { font-weight: 800; } .ab-seite .ab-hinweis, .ab-portal .ab-hinweis { margin: 4px 0 0; font-size: 0.85rem; color: var(--ab-weich); } .ab-seite .ab-links, .ab-portal .ab-links { list-style: none; margin: 0 0 16px; padding: 0; } -.ab-seite .ab-links a, .ab-portal .ab-links a { display: inline-block; min-height: 44px; line-height: 44px; font-weight: 600; } +.ab-seite .ab-links a, .ab-portal .ab-links a { display: flex; align-items: center; min-height: 44px; line-height: 1.35; padding: 4px 0; font-weight: 600; } .ab-seite .ab-bestaetigt, .ab-portal .ab-bestaetigt { background: var(--ab-ok-bg); color: var(--ab-ok-text); border-radius: 12px; padding: 12px 16px; margin: 0 0 16px; } diff --git a/test/ui/auftraege-kundenportal.ui.mjs b/test/ui/auftraege-kundenportal.ui.mjs new file mode 100644 index 0000000..9ba1197 --- /dev/null +++ b/test/ui/auftraege-kundenportal.ui.mjs @@ -0,0 +1,286 @@ +// Playwright-Prüfung der Kundenseiten zu Aufträgen (Strang L6): auftrag.html, Abschnitt „Aufträge“ im Dashboard, widerrufen.html. +// Statischer Server für public/ + page.route-Nachbau der /api-Antworten; nichts verlässt den Rechner. Nicht Teil von npm test. +// Aufruf: free -m; flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice node test/ui/auftraege-kundenportal.ui.mjs +// Bildschirmfotos: $SHOTS (Standard: Scratchpad auftraege-l6/). Exit-Code 1, wenn eine Prüfung scheitert. +import http from 'node:http' +import { readFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' +import { TOKEN, ab, privat, bestaetigt, eintrag } from '../auftraegeKundenportalDaten.js' + +const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') +const SHOTS = process.env.SHOTS || '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6' +await mkdir(SHOTS, { recursive: true }) +const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2' } + +const server = http.createServer(async (req, res) => { + try { + const datei = path.join(pub, new URL(req.url, 'http://x').pathname) + if (!datei.startsWith(pub + path.sep)) throw new Error('außerhalb von public/') + res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' }) + res.end(await readFile(datei)) + } catch { + res.writeHead(404) + res.end('nicht gefunden') + } +}) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) +const base = `http://127.0.0.1:${server.address().port}` + +let failed = 0 +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`) + if (!ok) failed += 1 +} +const norm = (text) => String(text).replace(/\s+/g, ' ').trim() // auch das geschützte Leerzeichen vor „€“ +const laufe = async (name, fn) => { + try { + await fn() + } catch (err) { + check(name, false, String(err.message).split('\n')[0]) + } +} + +let aufrufe = [] // alle /api-Anfragen der laufenden Prüfung +async function mocke(page, handler) { + aufrufe = [] + await page.unroute('**/api/**') + await page.route('**/api/**', async (route) => { + const req = route.request() + const roh = req.postData() + aufrufe.push({ pfad: new URL(req.url()).pathname, methode: req.method(), body: roh ? JSON.parse(roh) : undefined }) + const antwort = await handler(aufrufe.at(-1)) + if (antwort === 'abbrechen') return route.abort() + const { status = 200, body = {} } = antwort || {} + return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + }) +} + +const GET_AB = `/api/auftrag/${TOKEN}` +const POST_AB = `/api/auftrag/${TOKEN}/bestaetigen` +const KUNDE = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } +const RECHNUNGEN = [ + { id: 'in_1', source: 'stripe', number: 'WEBKLAR-0009', date: '2026-10-06', dueDate: '2026-10-20', amount: 500, balance: 500, status: 'Offen', paid: false, cancelled: false, overdue: false, portalUrl: 'https://invoice.stripe.test/i/in_1', hasPdf: true, projekt: 'Website Anelia Wolf' }, + { id: 'in_2', source: 'stripe', number: 'H96T-0004', date: '2026-10-01', dueDate: '', amount: 25, balance: 0, status: 'Bezahlt', paid: true, cancelled: false, overdue: false, portalUrl: '', hasPdf: true, projekt: '' }, +] +const AUFTRAEGE = [ + eintrag({ id: 'ab3', nummer: 'AB-2026-0003' }), + eintrag({ id: 'ab2', ...bestaetigt(privat()), nummer: 'AB-2026-0002', titel: 'Logo und Visitenkarten', kannBestaetigen: false }), + eintrag({ id: 'ab1', nummer: 'AB-2026-0001', titel: 'Wartung Oktober', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.' }), +] +const dashboardApi = (me) => ({ pfad, methode }) => { + if (pfad === '/api/auth/me') return { body: me } + if (pfad === '/api/invoices') return { body: { invoices: RECHNUNGEN, incomplete: false } } + // wie L5: in der Kundenansicht ist kannBestaetigen immer false + if (pfad === '/api/auftraege') return { body: { aktiv: true, auftraege: AUFTRAEGE.map((a) => (me.viewAs ? { ...a, kannBestaetigen: false } : a)) } } + if (methode === 'POST' && pfad === '/api/auftraege/ab3/bestaetigen') return { body: { auftrag: bestaetigt(ab({ nummer: 'AB-2026-0003' })) } } + if (pfad === '/api/projects') return { body: { projects: [] } } + if (pfad === '/api/features') return { body: { features: [] } } + if (pfad === '/api/activity') return { body: { tickets: [] } } + if (pfad === '/api/contact') return { body: { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' } } + if (pfad === '/api/chat/unread-count') return { body: { unread: 0 } } + if (pfad === '/api/chat/messages') return { body: { messages: [], unread: 0 } } + return { body: {} } +} +const postBody = () => JSON.stringify(aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body) + +const browser = await chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' })) +try { + for (const [label, viewport, mobil] of [['handy', { width: 390, height: 844 }, true], ['desktop', { width: 1440, height: 900 }, false]]) { + const ctx = await browser.newContext({ viewport, deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil, locale: 'de-DE', timezoneId: 'Europe/Berlin' }) + const page = await ctx.newPage() + page.setDefaultTimeout(30000) + const jsFehler = [] + page.on('pageerror', (e) => jsFehler.push(e.message)) + const foto = async (name) => { + // Einblend-Animationen des Dashboards abwarten; das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile: + // darum das Fenster kurz auf die Seitenhöhe ziehen (höchstens 3200 px) + await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {})))) + const fenster = page.viewportSize() + const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200)) + await page.setViewportSize({ width: fenster.width, height: hoehe }) + await page.screenshot({ path: `${SHOTS}/${name}-${label}.png`, fullPage: true }) + await page.setViewportSize(fenster) + } + const zustand = (z) => page.waitForSelector(`#ab-wurzel[data-zustand="${z}"]`) + const ohneUeberlauf = (selektor) => page.evaluate((s) => { const el = document.querySelector(s); return el.scrollWidth <= el.clientWidth + 1 }, selektor) + const oeffne = async (pfad, handler) => { + await mocke(page, handler) + await page.goto('about:blank') // frisch laden, auch wenn sich nur das Fragment unterscheidet + await page.goto(`${base}${pfad}`) + } + const oeffneAuftrag = (handler, adresse = `#t=${TOKEN}`) => oeffne(`/auftrag.html${adresse}`, handler) // K31 verlinkt #t= + const alsAb = (a, neu) => ({ pfad, methode }) => { + if (methode === 'GET' && pfad === GET_AB) return { body: { auftrag: a } } + if (methode === 'POST' && pfad === POST_AB) return { body: { auftrag: neu } } + } + + await laufe(`${label} unternehmen`, async () => { + await oeffneAuftrag(alsAb(ab(), bestaetigt(ab()))) + await zustand('offen') + check(`${label} offen_titel`, norm(await page.textContent('h1')) === 'Auftragsbestätigung AB-2026-0002') + check(`${label} formular_sie_form`, norm(await page.textContent('legend')) === 'Ihre Bestätigung') + check(`${label} paragraf_19_aus_der_api`, norm(await page.textContent('[data-hinweis]')) === 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + check(`${label} offen_positionen_und_summe`, (await page.locator('[data-position]').count()) === 2 && norm(await page.innerText('[data-gesamt]')) === 'Gesamtpreis 800,00 €') + check(`${label} offen_links`, (await page.getAttribute('[data-link="pdf"]', 'href')) === `/api/auftrag/${TOKEN}/pdf` && (await page.getAttribute('[data-link="agb"]', 'href')) === '/rechtliches/agb-2026-09-30.pdf') + check(`${label} offen_kein_zweites_haekchen`, (await page.locator('[data-feld="fruehbeginn"]').count()) === 0) + check(`${label} fusszeile`, (await page.locator('footer a').allInnerTexts()).join('|') === 'Datenschutz|Impressum') + check(`${label} kein_seitlicher_ueberlauf`, await ohneUeberlauf('html')) + const knopf = page.locator('[data-aktion="bestaetigen"]') + check(`${label} knopf_gesperrt_ohne_haken_und_gross_genug`, (await knopf.isDisabled()) && (await knopf.boundingBox()).height >= 44) + await foto('auftrag-offen-unternehmen') + await page.check('[data-feld="bestaetigung"]') + check(`${label} knopf_frei_mit_haken`, await knopf.isEnabled()) + await knopf.click() + await zustand('bestaetigt') + check(`${label} post_koerper`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false })) + check(`${label} bestaetigt_zeit`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr') + check(`${label} token_nicht_im_text`, !(await page.innerText('body')).includes(TOKEN)) + }) + + await laufe(`${label} rueckfall`, async () => { + await oeffneAuftrag(alsAb(ab()), `?t=${TOKEN}`) + await zustand('offen') + check(`${label} rueckfall_t_in_der_suche`, aufrufe.some((a) => a.methode === 'GET' && a.pfad === GET_AB)) + }) + + await laufe(`${label} privat`, async () => { + await oeffneAuftrag(alsAb(privat(), bestaetigt(privat()))) + await zustand('offen') + const frueh = page.locator('[data-feld="fruehbeginn"]') + check(`${label} privat_zweites_haekchen_frei_waehlbar`, (await frueh.isVisible()) && !(await frueh.isChecked())) + check(`${label} privat_kein_seitlicher_ueberlauf`, await ohneUeberlauf('html')) + await foto('auftrag-offen-privat') + await page.check('[data-feld="bestaetigung"]') + await page.click('[data-aktion="bestaetigen"]') + await zustand('bestaetigt') + check(`${label} privat_ohne_fruehbeginn`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false })) + check(`${label} privat_fruehester_beginn`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr Wir beginnen frühestens am 21.10.2026.') + await foto('auftrag-bestaetigt-privat') + }) + + await laufe(`${label} aufwand`, async () => { + await oeffneAuftrag(alsAb(ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten. Geschätzter Umfang: etwa 10 Stunden (unverbindlich).', zahlungsplanText: 'Monatlich nach Aufwand.' }))) + await zustand('offen') + check(`${label} aufwand_text_statt_tabelle`, (await page.locator('[data-aufwand]').count()) === 1 && (await page.locator('table').count()) === 0) + await foto('auftrag-aufwand') + }) + + await laufe(`${label} fehlerzustaende`, async () => { + await oeffneAuftrag(() => ({ status: 404, body: { error: 'Dieser Link ist ungültig oder abgelaufen.' } })) + await zustand('ungueltig') + check(`${label} ungueltig_kontaktweg`, (await page.locator('a[href="mailto:support@webklar.com"]').count()) === 1) + await foto('auftrag-ungueltig') + await oeffneAuftrag(() => ({ status: 410, body: { error: 'Diese Auftragsbestätigung wurde ersetzt.' } })) + await zustand('beendet') + check(`${label} beendet_titel`, norm(await page.textContent('h1')) === 'Diese Auftragsbestätigung wurde ersetzt.') + await foto('auftrag-beendet') + await oeffneAuftrag(() => ({ status: 429, body: {} })) + await zustand('limit') + let versuche = 0 + await oeffneAuftrag(({ pfad }) => { + if (pfad !== GET_AB) return undefined + versuche += 1 + return versuche === 1 ? { status: 500, body: { error: 'Interner Fehler.' } } : { body: { auftrag: ab() } } + }) + await zustand('fehler') + await foto('auftrag-fehler') + await page.click('[data-aktion="erneut"]') + await zustand('offen') + await oeffneAuftrag(() => 'abbrechen') + await zustand('fehler') + check(`${label} netzfehler_ohne_browsertext`, !/Failed to fetch|NetworkError|fetch failed/i.test(await page.innerText('body'))) + await oeffne('/auftrag.html', () => ({})) + await zustand('ungueltig') + check(`${label} ohne_token_keine_anfrage`, aufrufe.length === 0) + }) + + await laufe(`${label} portal`, async () => { + await oeffne('/dashboard.html#rechnungen', dashboardApi({ authenticated: true, customer: KUNDE })) + await page.waitForSelector('#orders-section:not(.hidden) .ab-karte') + check(`${label} portal_reihenfolge`, JSON.stringify(await page.$$eval('.ab-karte', (k) => k.map((x) => x.getAttribute('data-auftrag')))) === JSON.stringify(['ab3', 'ab2', 'ab1'])) + check(`${label} portal_du_form`, norm(await page.textContent('#orders-section > p')) === 'Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.' && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] legend')) === 'Deine Bestätigung') + check(`${label} portal_projekt_unter_rechnungsnummer`, norm(await page.textContent('.inv-no .inv-project')) === 'Website Anelia Wolf') + check(`${label} portal_offene_ab_aufgeklappt`, (await page.locator('.ab-karte[data-auftrag="ab3"] details').getAttribute('open')) !== null) + check(`${label} portal_kein_seitlicher_ueberlauf`, await ohneUeberlauf('#orders-section')) + await foto('portal-auftraege') + await page.check('.ab-karte[data-auftrag="ab3"] [data-feld="bestaetigung"]') + await page.click('.ab-karte[data-auftrag="ab3"] [data-aktion="bestaetigen"]') + await page.waitForSelector('.ab-karte[data-auftrag="ab3"] [data-bestaetigt]') + check(`${label} portal_post_und_status`, aufrufe.some((a) => a.methode === 'POST' && a.pfad === '/api/auftraege/ab3/bestaetigen') && postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }) && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] .ab-pille')) === 'Bestätigt') + }) + + await laufe(`${label} ansichtsmodus`, async () => { + const me = { authenticated: true, role: 'admin', viewAs: true, viewId: 'a'.repeat(32), viewExpiresAt: Date.now() + 3_600_000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer: KUNDE } + await oeffne('/dashboard.html#rechnungen', dashboardApi(me)) + await page.waitForSelector('#orders-section:not(.hidden) .ab-karte') + const karte = '.ab-karte[data-auftrag="ab3"]' + check(`${label} ansicht_gesperrt`, (await page.locator(`${karte} [data-aktion="bestaetigen"]`).isDisabled()) && (await page.locator(`${karte} [data-feld="bestaetigung"]`).isDisabled())) + check(`${label} ansicht_hinweis`, norm(await page.textContent(`${karte} [data-gesperrt]`)) === 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') + await foto('portal-auftraege-ansichtsmodus') + }) + + await laufe(`${label} widerruf`, async () => { + const api = ({ pfad, methode }) => { + if (pfad === '/api/auth/me') return { body: { authenticated: false } } + if (methode === 'POST' && pfad === '/api/abo/public/withdraw') return { body: { receivedAt: '2026-10-07T08:15:00.000Z' } } + } + const oeffneWiderruf = async () => { + await oeffne('/widerrufen.html', api) + await page.waitForFunction(() => document.getElementById('ab-nummer-wrap')?.classList.contains('hidden')) + } + const absenden = async (nummer) => { + await page.check('input[name="order"][value="projekt"]') + await page.fill('#name', 'Berta Berg') + await page.fill('#email', 'b@example.test') + if (nummer !== undefined) await page.fill('#abNummer', nummer) + await page.click('button[type="submit"]') + } + await oeffneWiderruf() + check(`${label} widerruf_feld_zuerst_versteckt`, !(await page.locator('#ab-nummer-wrap').isVisible())) + await absenden('AB-2026-01') + check(`${label} widerruf_feld_nur_bei_projektauftrag`, await page.locator('#ab-nummer-wrap').isVisible()) + check(`${label} widerruf_unpassende_nummer_gestoppt`, aufrufe.every((a) => a.methode !== 'POST') && norm(await page.textContent('#form-error')).startsWith('Die Nummer sieht anders aus')) + await foto('widerrufen-projekt') + await page.fill('#abNummer', 'ab 2026 0001') + await page.click('button[type="submit"]') + await page.waitForSelector('#done:not(.hidden)') + const mitNummer = aufrufe.find((a) => a.methode === 'POST')?.body + check(`${label} widerruf_nummer_vereinheitlicht`, mitNummer?.order === 'projekt' && mitNummer.abNummer === 'AB-2026-0001', JSON.stringify(mitNummer)) + check(`${label} widerruf_bestaetigung_nennt_nummer`, norm(await page.textContent('#done-list')).includes('Nummer der AuftragsbestätigungAB-2026-0001')) + await oeffneWiderruf() + await absenden() // ohne Nummer: freiwillig + await page.waitForSelector('#done:not(.hidden)') + const ohneNummer = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body + check(`${label} widerruf_ohne_nummer_geht_durch`, ohneNummer?.order === 'projekt' && ohneNummer.abNummer === '', JSON.stringify(ohneNummer)) + await oeffneWiderruf() + await page.fill('#name', 'Berta Berg') + await page.fill('#email', 'b@example.test') + await page.click('button[type="submit"]') + await page.waitForSelector('#done:not(.hidden)') + const andere = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body + check(`${label} widerruf_andere_art_ohne_nummer`, andere?.order === 'abschluss' && andere.abNummer === '', JSON.stringify(andere)) + }) + + check(`${label} keine_js_fehler`, jsFehler.length === 0, jsFehler.join(' | ')) + await ctx.close() + } + + // Dunkler Modus (Systemeinstellung): die Seite folgt den dunklen Farben der Mailvorlagen + const dunkel = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'de-DE', colorScheme: 'dark' }) + const seite = await dunkel.newPage() + await laufe('dunkel', async () => { + await mocke(seite, ({ pfad }) => (pfad === GET_AB ? { body: { auftrag: privat() } } : undefined)) + await seite.goto(`${base}/auftrag.html#t=${TOKEN}`) + await seite.waitForSelector('#ab-wurzel[data-zustand="offen"]') + check('dunkel hintergrund', (await seite.evaluate(() => getComputedStyle(document.body).backgroundColor)) === 'rgb(22, 22, 21)') + await seite.screenshot({ path: `${SHOTS}/auftrag-offen-privat-dunkel-handy.png`, fullPage: true }) + }) + await dunkel.close() +} finally { + await browser.close() + server.close() +} +console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok') +process.exit(failed ? 1 : 0)