Files
Webklar-Kundenbereich/public/auftrag-ansicht.js
T

261 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 : ''
}