261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
// 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 : ''
|
||
}
|