diff --git a/public/app.js b/public/app.js index b9251b8..e9d1938 100644 --- a/public/app.js +++ b/public/app.js @@ -584,6 +584,13 @@ function renderViewAsBanner(me) { } /* ------------------------------------------------------------- Rechnungen --- */ +// @rechnungsnummer-anfang +/** Rechnungsnummer mit dem Projekt darunter (Stripe-Metadaten projektName, Feld `projekt`); beides maskiert. */ +function invoiceNumberHtml(i) { + const project = i.projekt ? `${escapeHtml(i.projekt)}` : '' + 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() { ${invoices.map((i) => ` - ${escapeHtml(i.number)} + ${invoiceNumberHtml(i)} ${fmtDateShort(i.date)} ${fmtEuro(i.amount)} ${fmtEuro(i.balance)} @@ -632,6 +639,19 @@ async function loadCustomerInvoices() { } } +// Abschnitt "Aufträge" im Reiter Rechnungen (public/auftrag-portal.js): Fehler lassen den Abschnitt weg und stören die Rechnungen nicht. +function loadCustomerOrders() { + return import('/auftrag-portal.js') + .then((m) => m.initAuftraege({ + section: document.getElementById('orders-section'), + body: document.getElementById('orders-body'), + invoicesTitle: document.getElementById('invoices-title'), + request: api, + viewAs: chatState.viewAs, + })) + .catch(() => {}) +} + /* -------------------------------------------------------------- Aktivität --- */ // Abschnitt "Was wir zuletzt erledigt haben" unter Projekte: je Auftrag eine Karte mit Status, Fortschrittsleiste // und Arbeiten (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server). Zuerst die drei neuesten, @@ -2063,6 +2083,7 @@ async function initDashboardPage() { if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`) loadContracts({ afterCheckout: aboParam === 'success' }) loadCustomerInvoices() + loadCustomerOrders() activityScrollPending = currentHash() === 'fortschritt' loadCustomerActivity().then(scrollToActivity) diff --git a/public/auftrag-ansicht.js b/public/auftrag-ansicht.js new file mode 100644 index 0000000..c84c239 --- /dev/null +++ b/public/auftrag-ansicht.js @@ -0,0 +1,260 @@ +// Reine Ansichtslogik der Auftragsbestätigung (AB) im Kundenportal: Token, Beträge, Daten, Zustände, Status, AB-Nummer +// und alle Seitentexte. Ohne Importe, ohne DOM, ohne fetch; jede Funktion verträgt fehlende Felder. +// Die Anrede wählt der Aufrufer über ANREDE_SIE (öffentliche Seite, Widerrufsseite) oder ANREDE_DU (angemeldetes Portal). + +export const TOKEN_MUSTER = /^[A-Za-z0-9_-]{43}$/ +export const KONTAKT = { email: 'support@webklar.com', telefon: '0176 23726355' } +export const BESTAETIGUNG_FALLBACK = 'Ich bestätige den Auftrag zu diesen Bedingungen.' +export const ANREDE_SIE = 'sie' +export const ANREDE_DU = 'du' + +/** Texte, die in beiden Anreden gleich lauten (weder Du- noch Sie-Formen). */ +export const TEXTE_GEMEINSAM = { + titelOffen: 'Auftragsbestätigung', + leistung: 'Leistungsumfang', + aufwand: 'Abrechnung nach Aufwand', + zahlung: 'Zahlung', + termine: 'Termine', + unterlagen: 'Unterlagen', + spalten: ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'], + gesamtpreis: 'Gesamtpreis', + linkPdf: 'Auftragsbestätigung als PDF ansehen', + linkAgb: 'Allgemeine Geschäftsbedingungen (PDF)', + freiwillig: '(freiwillig)', + knopf: 'Auftrag bestätigen', + sendet: 'Wird gesendet …', + ansichtsmodus: 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.', + erneut: 'Erneut versuchen', + kartePdf: 'PDF ansehen', + karteDetails: 'Details ansehen', + karteDetailsBestaetigen: 'Details ansehen und bestätigen', +} + +/** Öffentliche Seite und Widerrufsseite (Sie-Form); enthält auch die Schlüssel, die das Portal in Du überschreibt. */ +export const TEXTE_SIE = { + laedt: 'Ihre Auftragsbestätigung wird geladen …', + geladen: 'Ihre Auftragsbestätigung wurde geladen.', + titelBestaetigt: 'Ihr Auftrag ist bestätigt.', + intro: 'Bitte prüfen Sie die Angaben. Mit einem Klick bestätigen Sie den Auftrag.', + kontaktVor: 'Sie erreichen uns unter', + keinJs: 'Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.', + abNummerUngueltig: 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.', + formular: 'Ihre Bestätigung', + speicherHinweis: 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.', + fruehbeginnWunsch: 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.', + fehler: { + keine: 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.', + haken: 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.', + anmeldung: 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.', + gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.', + zuviele: 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.', + sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.', + }, +} + +/** Angemeldetes Portal (Du-Form): nur die Schlüssel, die anders lauten. */ +export const TEXTE_DU = { + formular: 'Deine Bestätigung', + speicherHinweis: 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.', + fruehbeginnWunsch: 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.', + fehler: { + keine: 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.', + haken: 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.', + anmeldung: 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.', + gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.', + zuviele: 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.', + sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.', + }, +} + +/** Texte der gewählten Anrede (ohne Angabe: Sie). */ +export const texte = (anrede = ANREDE_SIE) => ({ ...TEXTE_GEMEINSAM, ...(anrede === ANREDE_DU ? TEXTE_DU : TEXTE_SIE) }) + +/* ------------------------------------------------------------------ Token und Links --- */ + +/** Token aus der Adresse: zuerst das Fragment (so verlinkt K31), dann die Suche (Rückfall). '' = ungültig. */ +export function tokenAusAdresse(suche = '', hash = '') { + for (const roh of [hash, suche]) { + const teil = String(roh ?? '').replace(/^[#?]/, '') + if (!teil) continue + const werte = new URLSearchParams(teil).getAll('t') + if (werte.length === 1 && TOKEN_MUSTER.test(werte[0])) return werte[0] + } + return '' +} + +export const pdfPfad = (token) => `/api/auftrag/${encodeURIComponent(token)}/pdf` + +/** AGB-Link: nur ein PDF unter /rechtliches/, sonst ''. */ +export const agbPfad = (agbUrl) => (typeof agbUrl === 'string' && /^\/rechtliches\/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$/.test(agbUrl) ? agbUrl : '') + +/* ------------------------------------------------------------------ Beträge und Zeiten --- */ + +const EURO = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }) +const BERLIN_TAG = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }) +const BERLIN_ZEIT = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', +}) + +/** Ganze Cent als deutscher Euro-Betrag (negativ mit dem Minuszeichen), sonst '–'. */ +export function euroDe(cents) { + if (!Number.isSafeInteger(cents)) return '–' + const text = EURO.format(cents / 100) + return text.startsWith('-') ? `\u{2212}${text.slice(1)}` : text +} + +/** 'YYYY-MM-DD' oder ISO-Zeitpunkt (Berliner Tag) oder 'TT.MM.JJJJ' als 'TT.MM.JJJJ', sonst ''. */ +export function datumDe(wert) { + if (typeof wert !== 'string') return '' + if (/^\d{2}\.\d{2}\.\d{4}$/.test(wert)) return wert + if (/^\d{4}-\d{2}-\d{2}$/.test(wert)) return `${wert.slice(8, 10)}.${wert.slice(5, 7)}.${wert.slice(0, 4)}` + if (/^\d{4}-\d{2}-\d{2}T/.test(wert)) { + const zeit = new Date(wert) + if (Number.isNaN(zeit.getTime())) return '' + return datumDe(BERLIN_TAG.format(zeit)) + } + return '' +} + +/** 'TT.MM.JJJJ, HH:MM Uhr' in Berliner Zeit, sonst ''. */ +export function zeitpunktDe(iso) { + if (typeof iso !== 'string' || !iso) return '' + const zeit = new Date(iso) + if (Number.isNaN(zeit.getTime())) return '' + const teile = Object.fromEntries(BERLIN_ZEIT.formatToParts(zeit).map((teil) => [teil.type, teil.value])) + return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr` +} + +/* ------------------------------------------------------------------ Zustand der AB --- */ + +export const zeigtFruehbeginn = (a) => typeof a?.texte?.fruehbeginn === 'string' && a.texte.fruehbeginn.trim() !== '' + +export const darfBestaetigen = (a) => !a?.bestaetigtAm && (a?.status === 'versendet' || a?.status === 'abgerechnet') + +const STATUS = { + versendet: { text: 'Versendet', ton: 'muted' }, + bestaetigt: { text: 'Bestätigt', ton: 'ok' }, + abgerechnet: { text: 'Abgerechnet', ton: 'ok' }, + storniert: { text: 'Storniert', ton: 'muted' }, + ersetzt: { text: 'Ersetzt', ton: 'muted' }, + widerrufen: { text: 'Widerrufen', ton: 'rot' }, +} + +export function statusAnzeige(a) { + if (a?.kannBestaetigen === true && !a.bestaetigtAm) return { text: 'Bitte bestätigen', ton: 'warn' } + const eintrag = STATUS[a?.status] + return eintrag ? { ...eintrag } : { text: '–', ton: 'muted' } +} + +export function kartenMeta(a) { + const teile = [] + const datum = datumDe(a?.datum) + if (datum) teile.push(`vom ${datum}`) + if (a?.art === 'aufwand') teile.push('nach Aufwand') + else if (Number.isSafeInteger(a?.summeCents)) teile.push(euroDe(a.summeCents)) + return teile.join(' · ') +} + +/** Kopie: zuerst die AB, die noch bestätigt werden kann, dann nach Nummer absteigend (Zeichenvergleich). */ +export function sortiereAuftraege(liste) { + if (!Array.isArray(liste)) return [] + return [...liste].sort((x, y) => { + const a = x?.kannBestaetigen === true ? 0 : 1 + const b = y?.kannBestaetigen === true ? 0 : 1 + if (a !== b) return a - b + const nx = String(x?.nummer ?? '') + const ny = String(y?.nummer ?? '') + return nx < ny ? 1 : nx > ny ? -1 : 0 + }) +} + +/* ------------------------------------------------------------------ Inhalte --- */ + +export function zusammenfassung(a) { + const auftrag = a ?? {} + const aufwand = auftrag.art === 'aufwand' + const kopf = [ + { label: 'Nummer', wert: auftrag.nummer || '' }, + { label: 'Datum', wert: datumDe(auftrag.datum) }, + { label: 'Projekt', wert: auftrag.titel || '' }, + ].filter((zeile) => zeile.wert) + const positionen = (Array.isArray(auftrag.positionen) ? auftrag.positionen : []).map((p) => ({ + beschreibung: String(p?.beschreibung ?? ''), + menge: String(p?.menge ?? ''), + einzelpreis: euroDe(p?.einzelpreisCents), + summe: euroDe(p?.summeCents), + })) + const termine = [ + { label: 'Start', wert: datumDe(auftrag.startAm) }, + { label: 'Voraussichtliche Fertigstellung', wert: datumDe(auftrag.fertigBis) }, + ].filter((zeile) => zeile.wert) + const hinweis = typeof auftrag.texte?.kleinunternehmer === 'string' ? auftrag.texte.kleinunternehmer.trim() : '' + return { + kopf, + art: aufwand ? 'aufwand' : 'festpreis', + positionen, + summe: !aufwand && Number.isSafeInteger(auftrag.summeCents) ? euroDe(auftrag.summeCents) : '', + aufwand: aufwand ? String(auftrag.aufwandText ?? '') : '', + zahlung: String(auftrag.zahlungsplanText ?? ''), + termine, + hinweis, + } +} + +export function bestaetigtZeilen(a, anrede = ANREDE_SIE) { + if (!a?.bestaetigtAm) return [] + const zeitpunkt = zeitpunktDe(a.bestaetigtAm) + const zeilen = [zeitpunkt ? `Bestätigt am ${zeitpunkt}` : 'Bestätigt'] + if (a.kundenart === 'privat') { + if (a.fruehbeginn === true) zeilen.push(texte(anrede).fruehbeginnWunsch) + else if (datumDe(a.fruehesterBeginn)) zeilen.push(`Wir beginnen frühestens am ${datumDe(a.fruehesterBeginn)}.`) + } + return zeilen +} + +/* ------------------------------------------------------------------ Antworten des Servers --- */ + +const BEENDET_GRUENDE = [['ersetzt', /ersetzt/i], ['storniert', /storniert/i], ['widerrufen', /widerrufen/i]] + +/** Zustand der öffentlichen Seite aus Status und Antwort (nur feste eigene Texte, nie Servertext). */ +export function zustandAusAntwort(status, body) { + if (status === 404) { + return { + zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', + text: 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.', + } + } + if (status === 410) { + const meldung = typeof body?.error === 'string' ? body.error : '' + const grund = BEENDET_GRUENDE.find(([, muster]) => muster.test(meldung))?.[0] || '' + return { + zustand: 'beendet', + titel: grund ? `Diese Auftragsbestätigung wurde ${grund}.` : 'Diese Auftragsbestätigung ist nicht mehr gültig.', + text: grund === 'ersetzt' + ? 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.' + : 'Bei Fragen melden Sie sich gern bei uns.', + } + } + if (status === 429) return { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' } + return { + zustand: 'fehler', titel: 'Das hat leider nicht geklappt', + text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.', + } +} + +/** Meldung beim Bestätigen nach Status (feste eigene Texte der gewählten Anrede). */ +export function fehlerMeldung(status, anrede = ANREDE_SIE) { + const fehler = texte(anrede).fehler + if (status === 0) return fehler.keine + if (status === 400) return fehler.haken + if (status === 401) return fehler.anmeldung + if (status === 403 || status === 409) return fehler.gesperrt + if (status === 429) return fehler.zuviele + return fehler.sonst +} + +/** Freiwillige AB-Nummer der Widerrufsseite: 'AB-JJJJ-NNNN' oder ''. */ +export function abNummerNormalisiert(eingabe) { + const text = String(eingabe ?? '').trim().toUpperCase().replace(/[\s\-\u{2010}-\u{2015}\u{2212}]+/gu, '-') + return /^AB-\d{4}-\d{4}$/.test(text) ? text : '' +} diff --git a/public/auftrag-dom.js b/public/auftrag-dom.js new file mode 100644 index 0000000..6e00d74 --- /dev/null +++ b/public/auftrag-dom.js @@ -0,0 +1,126 @@ +/* Auftragsbestätigung im Kundenportal: DOM-Bausteine (Zusammenfassung, Bestätigt-Block, Unterlagen, Bestätigen-Formular). + Kundentext und Servertext kommen ausschließlich über textContent in die Seite (h), nie über innerHTML. + Alle Texte stehen in auftrag-ansicht.js; die Anrede (Sie auf der öffentlichen Seite, Du im Portal) wählt der Aufrufer. */ +import { ANREDE_SIE, BESTAETIGUNG_FALLBACK, bestaetigtZeilen, fehlerMeldung, texte, zeigtFruehbeginn, zusammenfassung } from './auftrag-ansicht.js' + +/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */ +export function h(tag, attrs, ...kinder) { + const el = document.createElement(tag) + for (const [name, wert] of Object.entries(attrs || {})) { + if (wert === undefined || wert === null || wert === false) continue + if (name === 'class') el.className = wert + else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert) + else el.setAttribute(name, wert === true ? '' : wert) + } + for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind) + return el +} + +const zeile = (label, wert) => h('div', { class: 'ab-zeile' }, h('dt', null, label), h('dd', null, wert)) +const liste = (zeilen) => h('dl', { class: 'ab-liste' }, zeilen.map((z) => zeile(z.label, z.wert))) + +/** Zusammenfassung der AB (Kopf, Leistung bzw. Aufwand, Zahlung, Termine); ebene = Überschriftenebene der Abschnitte. */ +export function renderZusammenfassung(auftrag, { ebene = 2 } = {}) { + const t = texte(ANREDE_SIE) + const z = zusammenfassung(auftrag) + const kopfzeile = (text) => h(`h${ebene}`, { class: 'ab-h2' }, text) + const abschnitt = (name, titel, ...inhalt) => h('section', { class: 'ab-abschnitt', 'data-abschnitt': name }, kopfzeile(titel), ...inhalt) + const hinweis = z.hinweis ? h('p', { class: 'ab-hinweis', 'data-hinweis': true }, z.hinweis) : null + + const teile = [] + if (z.kopf.length) teile.push(liste(z.kopf)) + if (z.art === 'aufwand') { + teile.push(abschnitt('aufwand', t.aufwand, h('p', { class: 'ab-text', 'data-aufwand': true }, z.aufwand), hinweis)) + } else { + const tabelle = z.positionen.length + ? h('table', { class: 'ab-tabelle', 'data-positionen': true }, + h('thead', null, h('tr', null, t.spalten.map((spalte) => h('th', { scope: 'col' }, spalte)))), + h('tbody', null, z.positionen.map((p) => h('tr', { 'data-position': true }, + [p.beschreibung, p.menge, p.einzelpreis, p.summe].map((wert, i) => h('td', { 'data-label': t.spalten[i] }, wert)))))) + : null + const gesamt = z.summe ? h('p', { class: 'ab-gesamt', 'data-gesamt': true }, h('span', null, t.gesamtpreis), ' ', h('strong', null, z.summe)) : null + teile.push(abschnitt('leistung', t.leistung, tabelle, gesamt, hinweis)) + } + if (z.zahlung) teile.push(abschnitt('zahlung', t.zahlung, h('p', { class: 'ab-text', 'data-zahlung': true }, z.zahlung))) + if (z.termine.length) teile.push(abschnitt('termine', t.termine, liste(z.termine))) + return h('div', { class: 'ab-zusammenfassung', 'data-art': z.art }, teile) +} + +/** Block „bestätigt“ (Zeitpunkt, bei Privatpersonen Zeile zum Beginn); null ohne Bestätigung. */ +export function renderBestaetigtBlock(auftrag, { anrede = ANREDE_SIE } = {}) { + const zeilen = bestaetigtZeilen(auftrag, anrede) + if (!zeilen.length) return null + return h('div', { class: 'ab-bestaetigt', 'data-bestaetigt': true, role: 'status', tabindex: '-1' }, + zeilen.map((text, i) => h('p', null, i === 0 ? h('strong', null, text) : text))) +} + +/** Links auf die AB als PDF und die AGB (neuer Tab); null ohne Ziele. */ +export function renderDokumente({ pdfHref = '', agbHref = '' } = {}) { + const t = texte(ANREDE_SIE) + const link = (art, href, text) => h('li', null, h('a', { class: 'ab-link', 'data-link': art, href, target: '_blank', rel: 'noopener noreferrer' }, text)) + const eintraege = [pdfHref ? link('pdf', pdfHref, t.linkPdf) : null, agbHref ? link('agb', agbHref, t.linkAgb) : null].filter(Boolean) + return eintraege.length ? h('ul', { class: 'ab-links' }, eintraege) : null +} + +/** + * Formular zum Bestätigen: ein Häkchen, bei Privatpersonen ein zweites, freiwilliges (Frühbeginn). Sendet genau einmal: + * senden(koerper) → { ok: true, auftrag } | { ok: false, status, body }; nachErfolg(auftrag); nachEnde(status, body) bei 404/410. + * gesperrt (Text) = Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar. + */ +export function renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt = '', anrede = ANREDE_SIE } = {}) { + const t = texte(anrede) + let busy = false + const haken1 = h('input', { type: 'checkbox', 'data-feld': 'bestaetigung', required: true, disabled: Boolean(gesperrt) }) + const haken2 = zeigtFruehbeginn(auftrag) ? h('input', { type: 'checkbox', 'data-feld': 'fruehbeginn', disabled: Boolean(gesperrt) }) : null + const fehler = h('p', { class: 'ab-fehler', 'data-fehler': true, role: 'alert', hidden: true }) + const knopf = h('button', { type: 'submit', class: 'ab-knopf', 'data-aktion': 'bestaetigen', disabled: true }, t.knopf) + + const aktualisiere = () => { knopf.disabled = Boolean(gesperrt) || busy || !haken1.checked } + + const form = h('form', { + class: 'ab-bestaetigen', + novalidate: true, + onsubmit: async (ev) => { + ev.preventDefault() + if (busy || gesperrt || !haken1.checked) return + busy = true + fehler.hidden = true + knopf.disabled = true + knopf.setAttribute('aria-busy', 'true') + knopf.textContent = t.sendet + let ergebnis + try { + ergebnis = await senden({ bestaetigt: true, fruehbeginn: Boolean(haken2?.checked) }) + } catch { + ergebnis = { ok: false, status: 0, body: {} } + } + knopf.removeAttribute('aria-busy') + knopf.textContent = t.knopf + if (ergebnis?.ok) { + nachErfolg?.(ergebnis.auftrag) + return + } + const status = ergebnis?.status ?? 0 + if (status === 404 || status === 410) { + nachEnde?.(status, ergebnis?.body) + return + } + busy = false + aktualisiere() + fehler.textContent = fehlerMeldung(status, anrede) + fehler.hidden = false + }, + }, + h('fieldset', { class: 'ab-feldgruppe' }, + h('legend', null, t.formular), + h('label', { class: 'ab-haken' }, haken1, h('span', null, auftrag?.texte?.bestaetigung || BESTAETIGUNG_FALLBACK)), + haken2 ? h('label', { class: 'ab-haken' }, haken2, h('span', null, auftrag.texte.fruehbeginn, ' ', h('small', { class: 'ab-frei' }, t.freiwillig))) : null), + fehler, + knopf, + h('p', { class: 'ab-notiz' }, t.speicherHinweis), + gesperrt ? h('p', { class: 'ab-gesperrt', 'data-gesperrt': true }, gesperrt) : null) + + haken1.addEventListener('change', aktualisiere) + aktualisiere() + return form +} diff --git a/public/auftrag-portal.js b/public/auftrag-portal.js new file mode 100644 index 0000000..6f8e711 --- /dev/null +++ b/public/auftrag-portal.js @@ -0,0 +1,84 @@ +/* Abschnitt „Aufträge“ im Reiter Rechnungen des Kundenportals (geladen von app.js, loadCustomerOrders; request ist api aus app.js). + Kundentext kommt nur über textContent in die Seite. Alle Texte stehen in auftrag-ansicht.js; das Portal spricht in der Du-Form. */ +import { + ANREDE_DU, agbPfad, darfBestaetigen, kartenMeta, sortiereAuftraege, statusAnzeige, texte, +} from './auftrag-ansicht.js' +import { + h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung, +} from './auftrag-dom.js' + +export async function initAuftraege({ section, body, invoicesTitle, request, viewAs = false } = {}) { + if (!section || !body || typeof request !== 'function') return { sichtbar: false } + const t = texte(ANREDE_DU) + let eintraege = [] + + /** Bestätigen darf, wer der Server dafür freigibt; in der Kundenansicht zeigt die Oberfläche die offene AB mit gesperrtem Formular. */ + const wirksam = (e) => darfBestaetigen(e) && (viewAs || e.kannBestaetigen === true) + + async function ladeListe() { + const daten = await request('/api/auftraege') + if (daten?.aktiv !== true || !Array.isArray(daten.auftraege)) return [] + return daten.auftraege.filter((e) => e && typeof e.id === 'string' && e.id !== '') + } + + function karte(auftrag, offeneId) { + const kann = wirksam(auftrag) + const status = statusAnzeige({ ...auftrag, kannBestaetigen: kann }) + const pfad = `/api/auftraege/${encodeURIComponent(auftrag.id)}` + + async function senden(koerper) { + try { + const antwort = await request(`${pfad}/bestaetigen`, { method: 'POST', body: JSON.stringify(koerper) }) + return antwort?.auftrag ? { ok: true, auftrag: antwort.auftrag } : { ok: false, status: 0, body: {} } + } catch (err) { + return { ok: false, status: Number(err?.status) || 0, body: { error: String(err?.message ?? '') } } + } + } + function nachErfolg(neu) { + eintraege = eintraege.map((alt) => (alt.id === auftrag.id ? { ...alt, ...neu, id: alt.id, kannBestaetigen: false } : alt)) + zeichne(auftrag.id) + body.querySelector(`[data-auftrag="${CSS.escape(auftrag.id)}"] [data-bestaetigt]`)?.focus() + } + async function nachEnde() { + try { + eintraege = await ladeListe() + zeichne('') + } catch { + /* die Anzeige bleibt stehen */ + } + } + + return h('article', { class: 'ab-karte', 'data-auftrag': auftrag.id, 'data-status': auftrag.status || '' }, + h('div', { class: 'ab-karte-kopf' }, + h('div', null, + h('p', { class: 'ab-karte-nummer' }, h('strong', null, auftrag.nummer || '')), + auftrag.titel ? h('p', { class: 'ab-karte-titel' }, auftrag.titel) : null, + h('p', { class: 'ab-karte-meta' }, kartenMeta(auftrag))), + h('span', { class: `ab-pille ab-pille-${status.ton}` }, status.text)), + h('div', { class: 'ab-karte-aktionen' }, + h('a', { class: 'ab-link', 'data-link': 'pdf', href: `${pfad}/pdf`, target: '_blank', rel: 'noopener noreferrer' }, t.kartePdf)), + h('details', { class: 'ab-details', open: kann || auftrag.id === offeneId }, + h('summary', null, kann ? t.karteDetailsBestaetigen : t.karteDetails), + renderZusammenfassung(auftrag, { ebene: 4 }), + renderDokumente({ agbHref: agbPfad(auftrag.agbUrl) }), + renderBestaetigtBlock(auftrag, { anrede: ANREDE_DU }), + kann + ? renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt: viewAs ? t.ansichtsmodus : '', anrede: ANREDE_DU }) + : null)) + } + + function zeichne(offeneId) { + body.replaceChildren(...sortiereAuftraege(eintraege.map((e) => ({ ...e, kannBestaetigen: wirksam(e) }))).map((e) => karte(eintraege.find((x) => x.id === e.id), offeneId))) + } + + try { + eintraege = await ladeListe() + } catch { + return { sichtbar: false } + } + if (!eintraege.length) return { sichtbar: false } + zeichne('') + section.classList.remove('hidden') + invoicesTitle?.classList.remove('hidden') + return { sichtbar: true } +} diff --git a/public/auftrag-widerruf.js b/public/auftrag-widerruf.js new file mode 100644 index 0000000..082c1ab --- /dev/null +++ b/public/auftrag-widerruf.js @@ -0,0 +1,56 @@ +/* Widerrufsseite: Vertragsart „Projektauftrag“ mit freiwilliger Nummer der Auftragsbestätigung (Feld abNummer). + Rechtsfunktion: dieses Modul darf die anderen Vertragsarten nie stören. Fällt es aus, funktioniert der Widerruf wie bisher. + Das Feld ist nur bei „Projektauftrag“ sichtbar; eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden, + ein leeres Feld lässt den Widerruf durch (der Widerruf bleibt formfrei). Die Meldung steht in auftrag-ansicht.js. */ +import { abNummerNormalisiert, texte } from './auftrag-ansicht.js' + +export function initProjektWiderruf(form) { + const wrap = form?.querySelector('#ab-nummer-wrap') + const feld = form?.querySelector('#abNummer') + const fehler = form?.querySelector('#form-error') + if (!form || !wrap || !feld || !fehler) return + const gewaehlt = () => form.querySelector('[name="order"]:checked')?.value + let eigeneMeldung = false + + const meldungWeg = () => { + if (!eigeneMeldung) return + eigeneMeldung = false + fehler.textContent = '' + fehler.classList.add('hidden') + } + const zeige = () => { + const projekt = gewaehlt() === 'projekt' + wrap.classList.toggle('hidden', !projekt) + if (!projekt) { + feld.value = '' + meldungWeg() + } + } + + form.addEventListener('change', zeige) + form.addEventListener('click', (ev) => { + if (!ev.target?.closest?.('button[type="submit"], input[type="submit"]') || gewaehlt() !== 'projekt') return + const eingabe = feld.value.trim() + if (!eingabe) { + feld.value = '' + meldungWeg() + return + } + const nummer = abNummerNormalisiert(eingabe) + if (!nummer) { + ev.preventDefault() + fehler.textContent = texte().abNummerUngueltig + fehler.classList.remove('hidden') + eigeneMeldung = true + feld.focus() + return + } + feld.value = nummer + meldungWeg() + }) + zeige() +} + +if (typeof document !== 'undefined' && document.getElementById('decl-form') && document.getElementById('ab-nummer-wrap')) { + initProjektWiderruf(document.getElementById('decl-form')) +} diff --git a/public/auftrag.css b/public/auftrag.css new file mode 100644 index 0000000..4b1e49b --- /dev/null +++ b/public/auftrag.css @@ -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; } +} diff --git a/public/auftrag.html b/public/auftrag.html new file mode 100644 index 0000000..7cbf4a4 --- /dev/null +++ b/public/auftrag.html @@ -0,0 +1,42 @@ + + + + + + + + + + + Auftragsbestätigung – WEBklar + + + + + + + + ‹ Zurück zum Kundenbereich +
+
WEBklar
+
+
+
+
+ +

Ihre Auftragsbestätigung wird geladen …

+
+
+
+
+ + + + diff --git a/public/auftrag.js b/public/auftrag.js new file mode 100644 index 0000000..4207b9a --- /dev/null +++ b/public/auftrag.js @@ -0,0 +1,107 @@ +/*! WEBklar Aufträge: öffentliche Bestätigungsseite (auftrag.html#t=, Rückfall ?t=). Link aus der Mail K31. + * Der Token steht nur in der Adresse, in Links und im Speicher der Seite, nie in Text, Ansage, Fehlermeldung oder Protokoll. + * Keine Cookies (credentials: 'omit'), kein Browserspeicher, keine Drittanfragen. Alle Texte stehen in auftrag-ansicht.js (Sie-Form) + * und kommen nur über textContent in die Seite. */ +import { + KONTAKT, agbPfad, darfBestaetigen, pdfPfad, statusAnzeige, texte, tokenAusAdresse, zustandAusAntwort, +} from './auftrag-ansicht.js' +import { h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung } from './auftrag-dom.js' + +/** Einzige Anfrage der Seite: { status, body }; jeder Fehler des Netzes ist Status 0 (nie die Meldung des Browsers weiterreichen). */ +async function anfrage(token, aktion, { method = 'GET', body } = {}) { + try { + const antwort = await fetch(`/api/auftrag/${encodeURIComponent(token)}${aktion ? `/${aktion}` : ''}`, { + method, + credentials: 'omit', + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + const daten = await antwort.json().catch(() => ({})) + return { status: antwort.status, body: daten !== null && typeof daten === 'object' ? daten : {} } + } catch { + return { status: 0, body: {} } + } +} + +export async function initAuftragsseite({ root, ansage, suche = '', hash = '' }) { + const t = texte() + const token = tokenAusAdresse(suche, hash) + + const sage = (text) => { ansage.textContent = text } + const zeige = (zustand, ...kinder) => { + root.dataset.zustand = zustand + root.setAttribute('aria-busy', zustand === 'laedt' ? 'true' : 'false') + root.replaceChildren(...kinder.filter(Boolean)) + } + const kontakt = () => h('p', { class: 'ab-kontakt' }, + t.kontaktVor, ' ', + h('a', { href: `mailto:${KONTAKT.email}` }, KONTAKT.email), ' oder ', + h('a', { href: `tel:${KONTAKT.telefon.replace(/\D/g, '')}` }, KONTAKT.telefon), '.') + + function zeigeZustand({ zustand, titel, text }) { + zeige(zustand, + h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, titel), + h('p', { class: 'ab-text' }, text), + zustand === 'limit' ? null : kontakt(), + zustand === 'limit' || zustand === 'fehler' ? h('button', { type: 'button', class: 'ab-knopf', 'data-aktion': 'erneut', onclick: () => lade() }, t.erneut) : null) + sage(titel) + } + + async function senden(koerper) { + const { status, body } = await anfrage(token, 'bestaetigen', { method: 'POST', body: koerper }) + if (status === 200 && body.auftrag) return { ok: true, auftrag: body.auftrag } + return { ok: false, status: status === 200 ? 0 : status, body } + } + + function zeigeAuftrag(auftrag) { + const bestaetigt = Boolean(auftrag.bestaetigtAm) + const offen = darfBestaetigen(auftrag) + const status = statusAnzeige({ ...auftrag, kannBestaetigen: offen }) + zeige(bestaetigt ? 'bestaetigt' : 'offen', + h('p', { class: `ab-pille ab-pille-${status.ton}` }, status.text), + h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, bestaetigt ? t.titelBestaetigt : `${t.titelOffen} ${auftrag.nummer || ''}`.trim()), + bestaetigt ? null : h('p', { class: 'ab-intro' }, t.intro), + renderBestaetigtBlock(auftrag), + renderZusammenfassung(auftrag), + h('section', { class: 'ab-abschnitt' }, + h('h2', { class: 'ab-h2' }, t.unterlagen), + renderDokumente({ pdfHref: pdfPfad(token), agbHref: agbPfad(auftrag.agbUrl) })), + offen + ? renderBestaetigung(auftrag, { + senden, + nachErfolg: (neu) => { + zeigeAuftrag(neu) + sage(t.titelBestaetigt) + root.querySelector('[data-bestaetigt]')?.focus() + }, + nachEnde: (code, antwortBody) => zeigeZustand(zustandAusAntwort(code, antwortBody)), + }) + : null) + } + + async function lade() { + if (!token) { + zeigeZustand(zustandAusAntwort(404, {})) + return + } + zeige('laedt', h('p', { class: 'ab-laden' }, t.laedt)) + const { status, body } = await anfrage(token, '') + if (status === 200 && body.auftrag) { + zeigeAuftrag(body.auftrag) + sage(t.geladen) + } else { + zeigeZustand(zustandAusAntwort(status === 200 ? 0 : status, body)) + } + } + + await lade() +} + +if (typeof document !== 'undefined' && document.getElementById('ab-wurzel')) { + initAuftragsseite({ + root: document.getElementById('ab-wurzel'), + ansage: document.getElementById('ab-ansage'), + suche: window.location.search, + hash: window.location.hash, + }) +} diff --git a/public/dashboard.html b/public/dashboard.html index 0401f1d..716e943 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -26,6 +26,7 @@ +