Merge auftraege/l6-kundenportal: Kundenportal (Strang L6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3b6e00bf7
23 files changed
+2264
-4
No files matched your search
+22
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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 : ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
<link rel="stylesheet" href="/termine.css">
|
||||
<link rel="stylesheet" href="/shop.css">
|
||||
<link rel="stylesheet" href="/zeiten.css">
|
||||
<link rel="stylesheet" href="/auftrag.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -222,6 +223,14 @@
|
||||
<h1>Rechnungen</h1>
|
||||
<p>Deine Rechnungen ansehen, bezahlen und herunterladen.</p>
|
||||
</header>
|
||||
<!-- Aufträge: Auftragsbestätigungen ansehen und bestätigen (auftrag-portal.js, von app.js loadCustomerOrders); erscheint nur,
|
||||
wenn Aufträge freigeschaltet sind und der Kunde welche hat -->
|
||||
<section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title">
|
||||
<h2 id="orders-title">Aufträge</h2>
|
||||
<p class="kb-note">Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.</p>
|
||||
<div id="orders-body"></div>
|
||||
</section>
|
||||
<h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen</h2>
|
||||
<div class="kb-panel" id="invoices-body"><p class="kb-empty">Lade…</p></div>
|
||||
<p class="kb-note hidden" id="invoices-abo-note">Die Zahlungsart für dein Abo änderst du über dein <button type="button" class="kb-link" data-open-account>Profilbild</button> oben rechts unter „Abo verwalten & Rechnungen“.</p>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"><img src=x onerror=alert(1)> & "Bäckerei"</span>',
|
||||
)
|
||||
assert.match(app, /<td class="inv-no">\$\{invoiceNumberHtml\(i\)\}<\/td>/)
|
||||
assert.doesNotMatch(app, /\$\{i\.projekt\}/, 'der Projektname geht nie unmaskiert in eine Vorlage')
|
||||
})
|
||||
|
||||
test('auftrag.css: Projektname unter der Rechnungsnummer (Tabelle und Handy-Karte)', () => {
|
||||
assert.match(css, /\.inv-project\s*\{[^}]*display:\s*block/)
|
||||
assert.match(css, /\.inv-project\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'] })
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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'] })
|
||||
})
|
||||
@@ -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: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user