Buchungskalender: origin/main (Aufträge) zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd971309a8
145 files changed
+42590
-129
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,
|
||||
@@ -2064,6 +2084,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,
|
||||
})
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
<link rel="stylesheet" href="/shop.css">
|
||||
<link rel="stylesheet" href="/buchungen.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">
|
||||
@@ -232,6 +233,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>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="webklar-fassung" content="2026-09-30">
|
||||
<title>Allgemeine Geschäftsbedingungen – WEBklar</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||
h3 { font-size: 16.5px; margin: 22px 0 6px; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
.box.widerspruch { border: 2px solid #6d1fd1; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
|
||||
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
|
||||
a { color: #6d1fd1; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 18mm 17mm 18mm;
|
||||
@top-left { content: "WEBklar · AGB"; font: 8pt sans-serif; color: #888; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
|
||||
body { background: #fff; font-size: 10pt; line-height: 1.45; }
|
||||
main { max-width: none; padding: 0; }
|
||||
nav.top { display: none; }
|
||||
h1 { font-size: 20pt; }
|
||||
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.box, tr, ol.abs > li { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.html">Widerrufsbelehrung Projektaufträge</a> · <a href="/rechtliches/agb-2026-09-30.html">AGB</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
|
||||
|
||||
<div class="brand">WEBKLAR</div>
|
||||
<h1>Allgemeine Geschäftsbedingungen</h1>
|
||||
|
||||
<p class="meta"><strong>Fassung 2026-09-30</strong> · Wortgleiche Kopie der Allgemeinen Geschäftsbedingungen auf webklar.com/agb</p>
|
||||
|
||||
<h2>Kaufvertrag – WEBklar (Modularer Projektvertrag)</h2>
|
||||
|
||||
<p><strong>zwischen</strong></p>
|
||||
|
||||
<p>WEBklar (im Folgenden „Anbieter“)</p>
|
||||
|
||||
<p><strong>und</strong></p>
|
||||
|
||||
<p>Kunde laut Angebot (im Folgenden „Kunde“)</p>
|
||||
|
||||
<h2>1. Vertragsgegenstand</h2>
|
||||
|
||||
<p>(1) Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.</p>
|
||||
|
||||
<p>(2) Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.</p>
|
||||
|
||||
<p>(3) Maßgeblich ist das jeweils angenommene Angebot von WEBklar.</p>
|
||||
|
||||
<h2>2. Leistungsart</h2>
|
||||
|
||||
<p>(1) Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.</p>
|
||||
|
||||
<p>(2) Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.</p>
|
||||
|
||||
<p>(3) WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.</p>
|
||||
|
||||
<h2>3. Leistungsmodul A – Webseite (einmalig)</h2>
|
||||
|
||||
<p>(aktiv, wenn im Angebot enthalten)</p>
|
||||
|
||||
<p>(1) WEBklar erstellt eine individuelle Webseite gemäß Angebot.</p>
|
||||
|
||||
<p>(2) Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.</p>
|
||||
|
||||
<p>(3) Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.</p>
|
||||
|
||||
<p>(4) Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.</p>
|
||||
|
||||
<p>(5) Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.</p>
|
||||
|
||||
<h2>4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig)</h2>
|
||||
|
||||
<p>(aktiv, wenn im Angebot enthalten)</p>
|
||||
|
||||
<p>(1) WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.</p>
|
||||
|
||||
<p>(2) Die Leistung stellt eine reine Entwicklungsleistung dar.</p>
|
||||
|
||||
<p>(3) Optional kann eine Beratungsleistung Bestandteil des Projektes sein.</p>
|
||||
|
||||
<p>(4) Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.</p>
|
||||
|
||||
<p>(5) Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.</p>
|
||||
|
||||
<h2>5. Leistungsmodul C – Hosting</h2>
|
||||
|
||||
<p>(aktiv, wenn im Angebot enthalten)</p>
|
||||
|
||||
<p>(1) Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die Vertragsbedingungen für die WEBklar-Abos mit Leistungsbeschreibungen; sie gehen diesen AGB vor.</p>
|
||||
|
||||
<p>(2) Bestehende Hosting-Verträge mit jährlicher Laufzeit behalten die bei ihrem Abschluss vereinbarten Bedingungen. Abweichend davon können Verbraucher solche Verträge nach einer automatischen Verlängerung jederzeit mit einer Frist von einem Monat kündigen.</p>
|
||||
|
||||
<p>(3) WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.</p>
|
||||
|
||||
<h2>6. Quellcode und Eigentum</h2>
|
||||
|
||||
<h3>6.1 Webseite</h3>
|
||||
|
||||
<p>(1) Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.</p>
|
||||
|
||||
<p>(2) Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.</p>
|
||||
|
||||
<p>(3) Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.</p>
|
||||
|
||||
<h3>6.2 Apps, Automatisierungen und Backend</h3>
|
||||
|
||||
<p>(1) Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.</p>
|
||||
|
||||
<p>(2) Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.</p>
|
||||
|
||||
<p>(3) Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.</p>
|
||||
|
||||
<h2>7. Mitwirkungspflichten des Kunden</h2>
|
||||
|
||||
<p>(1) Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.</p>
|
||||
|
||||
<p>(2) Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.</p>
|
||||
|
||||
<p>(3) WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.</p>
|
||||
|
||||
<h2>8. Abnahme</h2>
|
||||
|
||||
<p>(1) Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.</p>
|
||||
|
||||
<p>(2) Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.</p>
|
||||
|
||||
<p>(3) Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.</p>
|
||||
|
||||
<h2>9. Vergütung und Zahlung</h2>
|
||||
|
||||
<p>(1) Die Vergütung ergibt sich aus dem Angebot.</p>
|
||||
|
||||
<p>(2) Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede Unterstützung ist im Kundenportal einsehbar.</p>
|
||||
|
||||
<p>(3) Projektleistungen sind nach Vereinbarung fällig.</p>
|
||||
|
||||
<p>(4) Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.</p>
|
||||
|
||||
<h2>10. Zahlungsverzug</h2>
|
||||
|
||||
<p>(1) Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.</p>
|
||||
|
||||
<p>(2) Danach ist WEBklar berechtigt:</p>
|
||||
|
||||
<ul>
|
||||
<li>Leistungen zu sperren</li>
|
||||
<li>Verzugszinsen zu berechnen</li>
|
||||
<li>den Vertrag außerordentlich zu kündigen</li>
|
||||
</ul>
|
||||
|
||||
<h2>11. Haftung</h2>
|
||||
|
||||
<p>(1) WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.</p>
|
||||
|
||||
<p>(2) Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.</p>
|
||||
|
||||
<p>(3) Der Kunde bewahrt Kopien der Inhalte und Daten auf, die er WEBklar übergibt oder selbst einpflegt. Für den Verlust solcher Inhalte und Daten haftet WEBklar außer in den Fällen von Absatz 1 nur in Höhe des Aufwands, der für ihre Wiederherstellung nötig gewesen wäre, wenn der Kunde diese Kopien aufbewahrt hätte.</p>
|
||||
|
||||
<p>(4) Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.</p>
|
||||
|
||||
<h2>12. Kündigung</h2>
|
||||
|
||||
<p>(1) Laufzeitverträge können zum Ende der jeweiligen Laufzeit gekündigt werden. Laufzeit und Kündigung der Abos richten sich nach den Vertragsbedingungen für die WEBklar-Abos.</p>
|
||||
|
||||
<p>(2) Das Recht zur außerordentlichen Kündigung bleibt unberührt.</p>
|
||||
|
||||
<h2>13. Referenzen</h2>
|
||||
|
||||
<p>WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden.</p>
|
||||
|
||||
<h2>14. Schlussbestimmungen</h2>
|
||||
|
||||
<p>(1) Es gilt deutsches Recht.</p>
|
||||
|
||||
<p>(2) Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.</p>
|
||||
|
||||
<p>(3) Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
d271a7ed066ee758dd5dd85b865134a4f9f65e1bc2ee375af7ab959b6455d2de
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="webklar-fassung" content="2026-10-06">
|
||||
<title>Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge) – WEBklar</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||
h3 { font-size: 16.5px; margin: 22px 0 6px; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
.box.widerspruch { border: 2px solid #6d1fd1; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
|
||||
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
|
||||
a { color: #6d1fd1; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 18mm 17mm 18mm;
|
||||
@top-left { content: "WEBklar · Widerrufsbelehrung Projektaufträge"; font: 8pt sans-serif; color: #888; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
|
||||
body { background: #fff; font-size: 10pt; line-height: 1.45; }
|
||||
main { max-width: none; padding: 0; }
|
||||
nav.top { display: none; }
|
||||
h1 { font-size: 20pt; }
|
||||
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.box, tr, ol.abs > li { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.html">Widerrufsbelehrung Projektaufträge</a> · <a href="/rechtliches/agb-2026-09-30.html">AGB</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
|
||||
|
||||
<div class="brand">WEBKLAR</div>
|
||||
<h1>Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)</h1>
|
||||
|
||||
<p class="meta"><strong>Fassung 2026-10-06</strong></p>
|
||||
|
||||
<p>Das Widerrufsrecht gilt nur für Verbraucher. Wir fügen die Belehrung und das Muster-Widerrufsformular jeder Auftragsbestätigung an eine Privatperson bei und zeigen sie hier zusätzlich allen Kunden. Die Belehrung gilt für den Auftrag, den die jeweilige Auftragsbestätigung bestätigt.</p>
|
||||
|
||||
<h2>Widerrufsbelehrung</h2>
|
||||
|
||||
<h3>Widerrufsrecht</h3>
|
||||
|
||||
<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.</p>
|
||||
|
||||
<p>Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.</p>
|
||||
|
||||
<p>Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.</p>
|
||||
|
||||
<p>Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.</p>
|
||||
|
||||
<p>Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.</p>
|
||||
|
||||
<h3>Folgen des Widerrufs</h3>
|
||||
|
||||
<p>Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.</p>
|
||||
|
||||
<p>Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.</p>
|
||||
|
||||
<h2>Muster-Widerrufsformular</h2>
|
||||
|
||||
<p>(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)</p>
|
||||
|
||||
<p>– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:</p>
|
||||
|
||||
<p>– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)</p>
|
||||
|
||||
<p>– Bestellt am (*)/erhalten am (*)</p>
|
||||
|
||||
<p>– Name des/der Verbraucher(s)</p>
|
||||
|
||||
<p>– Anschrift des/der Verbraucher(s)</p>
|
||||
|
||||
<p>– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)</p>
|
||||
|
||||
<p>– Datum</p>
|
||||
|
||||
<p>(*) Unzutreffendes streichen.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
f9948c0a656e5eaf62c964a933d4fda4b162cf1cdc4069632e1068c6488b8408
|
||||
@@ -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