Kundenportal Aufträge: reine Ansichtslogik (Token, Beträge, Zustände, Status, AB-Nummer)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:12:34 +00:00
1 parent 987268e2f0
commit e6a0fc9acf
3 files changed
+536

No files matched your search

+260
View File
@@ -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 : ''
}