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>
|
||||
Reference in new issue
Block a user