Merge auftraege/l6-kundenportal: Kundenportal (Strang L6)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:21:18 +00:00
commit f3b6e00bf7
23 files changed
+2264 -4

No files matched your search

+22 -1
View File
@@ -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)
+260
View File
@@ -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 : ''
}
+126
View File
@@ -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
}
+84
View File
@@ -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 }
}
+56
View File
@@ -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'))
}
+161
View File
@@ -0,0 +1,161 @@
/* 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-flex; align-items: center; min-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: 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;
}
.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%; }
}
/* ------------------------------------------------------------------ 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: 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; }
@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; }
}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f1f0ec" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#161615" media="(prefers-color-scheme: dark)">
<title>Auftragsbestätigung – WEBklar</title>
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/auftrag.css">
<noscript><style>.ab-laden{display:none}</style></noscript>
</head>
<body class="ab-seite">
<!-- Bestätigungsseite für eine Auftragsbestätigung (Link aus der Mail K31: /auftrag.html#t=<Token>, Rückfall ?t=<Token>).
Der Token steht nur in der Adresse und im Speicher der Seite: keine Cookies, kein Browserspeicher, keine Fremdanfragen,
nichts im Protokoll (auftrag.js). Ohne Anmeldung erreichbar, nicht für Suchmaschinen. -->
<a class="ab-app-back" href="/dashboard.html">‹ Zurück zum Kundenbereich</a>
<header class="ab-kopf">
<div class="ab-innen"><img class="ab-logo" src="/webklar-logo.png" width="36" height="36" alt=""><span class="ab-marke">WEBklar</span></div>
</header>
<main class="ab-haupt">
<div class="ab-innen">
<div class="ab-wurzel" id="ab-wurzel" data-zustand="laedt" aria-busy="true">
<noscript><p>Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.</p></noscript>
<p class="ab-laden">Ihre Auftragsbestätigung wird geladen …</p>
</div>
</div>
</main>
<div class="ab-vh" id="ab-ansage" aria-live="polite"></div>
<footer class="ab-fuss">
<div class="ab-innen">
<a href="/datenschutz.html" target="_blank" rel="noopener noreferrer">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener noreferrer">Impressum</a>
</div>
</footer>
<script type="module" src="/auftrag.js"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
/*! WEBklar Aufträge: öffentliche Bestätigungsseite (auftrag.html#t=<Token>, 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,
})
}
+9
View File
@@ -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 &amp; Rechnungen“.</p>
</section>
+9 -1
View File
@@ -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>
+3 -1
View File
@@ -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 || '',
}
}
+211
View File
@@ -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' })
})
@@ -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">&lt;img src=x onerror=alert(1)&gt; &amp; &quot;Bäckerei&quot;</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/)
})
+65
View File
@@ -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
}
+249
View File
@@ -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 = '<img src=x onerror=alert(1)> & <b>fett</b> "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'] })
})
+52
View File
@@ -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, /<meta name="robots" content="noindex, nofollow">/)
assert.match(html, /<meta name="referrer" content="no-referrer">/)
assert.match(html, /<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">/)
assert.match(html, /<meta name="color-scheme" content="light dark">/)
assert.match(html, /<title>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')
})
+159
View File
@@ -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)')
})
@@ -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',
])
})
+160
View File
@@ -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/)
})
+110
View File
@@ -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'] })
})
+1
View File
@@ -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: '',
})
})
+1 -1
View File
@@ -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', () => {
+286
View File
@@ -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)