/* Rechnungsanalyse / Projektkalkulation. Wird aus dem Admin-Panel bei einem Lead geoeffnet (kalkulation.html?customer=) und im Gespraech vor Ort oder am Telefon ausgefuellt. Ergebnis ist bewusst KEIN eigener Datensatz: die Zusammenfassung wird kopiert und im Akquise-Ticket dokumentiert (dort wird gearbeitet). Damit ein angefangenes Gespraech nicht verloren geht, liegt der Stand pro Kunde im localStorage des Browsers. */ /* --------------------------------------------------------------- Katalog --- */ const MWST = 19 const CATS = [ { id: 'web', name: 'Website', kenso: false, hint: 'Alles, was zu einer Website gehört. Das Grundpaket deckt Startseite und drei Unterseiten ab.', groups: [ { g: 'Grundlage', items: [ { id: 'w1', n: 'Grundpaket Website', d: 'Startseite und bis zu 3 Unterseiten, Texte, Design, mobil, Impressum und Datenschutz', h: 2 }, { id: 'w2', n: 'Weitere Unterseite inkl. Text', d: 'Anzahl eintragen', h: 0.25, q: true }, { id: 'w3', n: 'Bilder beschaffen und aufbereiten', h: 0.5 }, { id: 'w4', n: 'Logo neu gestalten', d: 'einfaches Logo, kein Corporate Design', h: 0.5 }, { id: 'w5', n: 'Domain registrieren und einrichten', h: 0.25 }, { id: 'w6', n: 'Bestehende Domain umziehen', d: 'Relaunch: Kunde bringt Domain mit', h: 0.5 }, { id: 'w7', n: 'Inhalte von der alten Seite übernehmen', d: 'Relaunch', h: 1 }, { id: 'w8', n: 'E-Mail-Postfächer einrichten', h: 0.5 }, ] }, { g: 'Funktionen', items: [ { id: 'f1', n: 'Kontaktformular', h: 0.25 }, { id: 'f2', n: 'Anfahrt, Karte, Öffnungszeiten', h: 0.25 }, { id: 'f3', n: 'Bildergalerie oder Referenzen', h: 0.5 }, { id: 'f4', n: 'Google-Bewertungen einbinden', h: 0.5 }, { id: 'f5', n: 'Cookie-Banner und Consent-Tool', h: 0.5 }, { id: 'f6', n: 'Newsletter-Anmeldung', h: 0.5 }, { id: 'f7', n: 'Stellenanzeigen mit Bewerbungsformular', h: 0.75 }, { id: 'f8', n: 'Blog- oder Newsbereich', h: 1 }, { id: 'f9', n: 'Terminbuchung', h: 1.5 }, { id: 'f10', n: 'Weitere Sprache', d: 'Anzahl der zusätzlichen Sprachen', h: 1, q: true }, ] }, { g: 'Sichtbarkeit', items: [ { id: 's1', n: 'Google-Profil einrichten oder aufräumen', h: 0.75 }, { id: 's2', n: 'SEO-Grundoptimierung', h: 1 }, { id: 's3', n: 'Statistik und Tracking einrichten', h: 0.5 }, { id: 's4', n: 'Google Ads einrichten', d: 'Werbebudget zahlt der Kunde selbst', h: 1.5 }, ] }, ], }, { id: 'shop', name: 'Onlineshop', kenso: true, hint: 'Shop-Positionen stimmt Kenso mit ab — du nimmst hier nur den Bedarf auf.', groups: [ { g: 'Shop', items: [ { id: 'o1', n: 'Shop-Grundeinrichtung', d: 'Warenkorb, Kasse, Bestellabwicklung', h: 6 }, { id: 'o2', n: 'Produkte anlegen', d: 'Anzahl in 25er-Schritten', h: 1, q: true }, { id: 'o3', n: 'Zahlungsanbindung', h: 1.5 }, { id: 'o4', n: 'Versand- und Steuerregeln', h: 1 }, { id: 'o5', n: 'Gutscheine und Rabatte', h: 1 }, { id: 'o6', n: 'Kundenkonten im Shop', h: 1.5 }, { id: 'o7', n: 'Anbindung an die Warenwirtschaft', h: 4 }, ] }, ], }, { id: 'soft', name: 'Softwareprojekt', kenso: true, hint: 'Eigenentwicklung: Umfang aufnehmen, den Preis bestätigt Kenso.', groups: [ { g: 'Fundament', items: [ { id: 'p1', n: 'Projektfundament', d: 'Architektur, Datenmodell, Grundgerüst, Deployment', h: 8 }, { id: 'p2', n: 'Datenbank aufsetzen und modellieren', h: 4 }, { id: 'p3', n: 'Benutzerkonten und Login', h: 3 }, { id: 'p4', n: 'Rollen und Rechte', h: 2.5 }, ] }, { g: 'Oberfläche und Verwaltung', items: [ { id: 'a1', n: 'Admin-Panel Grundgerüst', h: 5 }, { id: 'a2', n: 'Weiterer Verwaltungsbereich', d: 'je Bereich zum Anlegen, Bearbeiten, Löschen', h: 1.5, q: true }, { id: 'a3', n: 'Produktkatalog mit Kategorien', h: 3 }, { id: 'a4', n: 'Such- und Filterfunktion', h: 2 }, { id: 'a5', n: 'Auswertungen und Dashboard', h: 3 }, { id: 'a6', n: 'Import und Export', d: 'CSV oder Excel', h: 1.5 }, { id: 'a7', n: 'Datei-Upload und Dokumentenablage', h: 1.5 }, { id: 'a8', n: 'Benachrichtigungen per E-Mail', h: 1.5 }, ] }, { g: 'App', items: [ { id: 'm1', n: 'Web-App', d: 'läuft im Browser, auch auf dem Handy', h: 6 }, { id: 'm2', n: 'Mobile App zusätzlich', d: 'iOS und Android, inkl. Store-Veröffentlichung', h: 12 }, { id: 'm3', n: 'Offline-Funktion', h: 4 }, { id: 'm4', n: 'Push-Benachrichtigungen', h: 2 }, ] }, { g: 'Schnittstellen', items: [ { id: 'i1', n: 'Schnittstelle zu einem Fremdsystem', d: 'Anzahl der Systeme', h: 4, q: true }, { id: 'i2', n: 'Zahlungsanbieter anbinden', h: 2 }, { id: 'i3', n: 'Datenübernahme aus einem Altsystem', h: 3 }, ] }, ], }, { id: 'auto', name: 'Automatisierung und KI', kenso: true, hint: 'Jeder Ablauf wird einzeln gezählt — lieber grob schätzen als weglassen.', groups: [ { g: 'Automatisierung', items: [ { id: 'z1', n: 'Wiederkehrenden Ablauf automatisieren', d: 'je Ablauf, z. B. Terminbestätigung oder Datenweitergabe', h: 3, q: true }, { id: 'z2', n: 'Automatische Angebots- oder Rechnungserzeugung', h: 3 }, { id: 'z3', n: 'Dokumente automatisch auslesen', d: 'Lieferscheine, Rechnungen, Formulare', h: 4 }, { id: 'z4', n: 'KI-Assistent mit eigenem Firmenwissen', d: 'laufende Nutzungskosten trägt der Kunde', h: 6 }, ] }, ], }, { id: 'run', name: 'Laufende Kosten', kenso: false, monthly: true, hint: 'Was nach dem Start jeden Monat läuft.', groups: [ { g: 'Monatlich', items: [ { id: 'r1', n: 'Wartung und Hosting', d: 'Betrieb, Backups, 1 Stunde Änderungen pro Monat', m: 50 }, { id: 'r2', n: 'Erhöhter Serverbedarf', d: 'eigene Datenbank oder App-Backend', m: 0, ask: true }, { id: 'r3', n: 'KI-Nutzung', d: 'ab 50 € je Anliegen pro Monat – je nach Umfang mehr', m: 50, q: true }, ] }, ], }, ] const STAFFEL = [{ max: 5, r: 150 }, { max: 15, r: 130 }, { max: 40, r: 115 }, { max: 1e9, r: 100 }] const PEOPLE = { justin: { name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J', slots: { 1: ['09:00', '10:00', '11:00', '16:00', '17:00'], 2: ['09:00', '10:00', '16:00', '17:00'], 3: ['09:00', '10:00', '11:00', '16:00'], 4: ['09:00', '10:00', '16:00', '17:00'], 5: ['09:00', '10:00', '11:00'], }, }, kenso: { name: 'Kenso', role: 'Shop, Software, Automatisierung', initial: 'K', slots: { 2: ['14:00', '15:00', '18:00'], 4: ['14:00', '15:00', '18:00'], 6: ['10:00', '11:00'] }, }, } const ITEM_BY_ID = {} for (const cat of CATS) { for (const grp of cat.groups) { for (const item of grp.items) ITEM_BY_ID[item.id] = { cat, item } } } /* ----------------------------------------------------------------- State --- */ const state = { types: { web: true }, sel: {}, qty: {}, wantTermin: false, person: 'justin', day: '', slot: '', termNote: '', notes: '', portalEmail: '', portalPass: '', } let customer = null let storageKey = 'wk-kalkulation:neu' /* --------------------------------------------------------------- Helfer --- */ function escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function fmt(n) { return Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } function eur(n) { return Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' €' } async function api(path) { const response = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, }) const data = await response.json().catch(() => ({})) if (!response.ok) throw new Error(data.error || `Fehler ${response.status}`) return data } function el(id) { return document.getElementById(id) } /* ------------------------------------------------------- Zwischenspeicher --- */ function saveState() { try { localStorage.setItem(storageKey, JSON.stringify({ ...state, savedAt: new Date().toISOString() })) } catch { /* z. B. privater Modus: dann eben ohne Zwischenspeicher */ } } function restoreState() { try { const raw = localStorage.getItem(storageKey) if (!raw) return const saved = JSON.parse(raw) for (const key of Object.keys(state)) { if (saved[key] !== undefined && saved[key] !== null) state[key] = saved[key] } } catch { /* kaputter Eintrag: ignorieren */ } } /* --------------------------------------------------------------- Rechnen --- */ function picked() { const out = [] for (const cat of CATS) { for (const grp of cat.groups) { for (const item of grp.items) { if (!state.sel[item.id]) continue const q = item.q ? (state.qty[item.id] || 1) : 1 out.push({ cat, item, q, h: cat.monthly ? 0 : (item.h || 0) * q, m: cat.monthly ? (item.m || 0) * q : 0, }) } } } return out } function totals() { const sel = picked() const hrsNum = sel.reduce((a, x) => a + x.h, 0) const rateNum = STAFFEL.find((s) => hrsNum <= s.max).r const netNum = hrsNum * rateNum const monthlyItems = sel.filter((x) => x.cat.monthly) const askServer = monthlyItems.some((x) => x.item.ask) const mSum = monthlyItems.reduce((a, x) => a + x.m, 0) const kenso = sel.some((x) => x.cat.kenso) let payTitle = 'Noch nichts gewählt' let payText = 'Kreuze an, was der Kunde braucht.' if (netNum >= 3000) { payTitle = '40 / 40 / 20' payText = `${eur(netNum * 0.4)} bei Auftrag, ${eur(netNum * 0.4)} bei Designfreigabe, ${eur(netNum * 0.2)} nach Fertigstellung.` } else if (netNum >= 1000) { payTitle = '50 % Anzahlung' payText = `${eur(netNum * 0.5)} bei Auftragserteilung, der Rest nach der Abnahme.` } else if (netNum > 0) { payTitle = 'Keine Anzahlung' payText = 'Eine Rechnung nach der Abnahme, zahlbar in 14 Tagen.' } let flagTitle = 'Noch offen.' let flagText = 'Sobald etwas gewählt ist, steht hier, ob du selbst abschließen kannst.' let tone = 'neutral' if (netNum > 0 || monthlyItems.length) { if (kenso) { tone = 'kenso' flagTitle = 'Kenso gibt frei.' flagText = 'Ein paar Positionen laufen über ihn. Preis und Termin nennst du erst nach seinem Okay — die Zahl hier ist deine Orientierung.' } else if (hrsNum <= 5) { tone = 'ok' flagTitle = 'Kannst du selbst zusagen.' flagText = 'Vorschau in unter einer Woche, fertig in maximal einem Monat.' } else { tone = 'talk' flagTitle = 'Termin kurz abstimmen.' flagText = `Bei ${fmt(hrsNum)} Stunden legt Kenso den Fertigstellungstermin fest. Der Preis steht.` } } return { sel, hrsNum, rateNum, netNum, grossNum: netNum * (1 + MWST / 100), mSum, askServer, monthlyItems, hasMonthly: monthlyItems.length > 0, payTitle, payText, flagTitle, flagText, tone, monthly: eur(mSum) + (askServer ? ' +' : ''), } } /* ---------------------------------------------------------------- Termin --- */ function slotsFor(personId, date) { return PEOPLE[personId].slots[date.getDay()] || [] } function dayKey(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } function terminDays() { const today = new Date() today.setHours(0, 0, 0, 0) const list = [] for (let i = 1; i <= 14; i += 1) { const d = new Date(today) d.setDate(today.getDate() + i) list.push({ key: dayKey(d), date: d, slots: slotsFor(state.person, d) }) } return list } function selectedDay() { return terminDays().find((d) => d.key === state.day && d.slots.length) || null } function longDay(date) { return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }) } function terminLine() { const day = selectedDay() if (!state.wantTermin || !day || !state.slot) return '' return `${longDay(day.date)} um ${state.slot} Uhr mit ${PEOPLE[state.person].name}` } /* ---------------------------------------------------------- Zusammenfassung --- */ function summaryText() { const t = totals() const lines = ['PROJEKTKALKULATION webklar', ''] lines.push(`Kunde: ${customer?.name || '______________________'}`) if (customer?.code) lines.push(`Kundennr.: ${customer.code}`) if (customer?.email) lines.push(`E-Mail: ${customer.email}`) if (customer?.phone) lines.push(`Telefon: ${customer.phone}`) lines.push(`Datum: ${new Date().toLocaleDateString('de-DE')}`) lines.push('', 'UMFANG') let cur = '' for (const x of t.sel.filter((s) => !s.cat.monthly)) { if (x.cat.name !== cur) { cur = x.cat.name lines.push('', `[${cur}]`) } lines.push(`- ${x.item.n}${x.q > 1 ? ` (${x.q}×)` : ''} – ${fmt(x.h)} Std`) } if (!t.sel.length) lines.push('(noch nichts gewählt)') lines.push('') lines.push(`Summe: ${fmt(t.hrsNum)} Stunden`) lines.push(`Stundensatz: ${eur(t.rateNum)}`) lines.push(`Projektpreis: ${eur(t.netNum)} netto (${eur(t.grossNum)} brutto inkl. ${MWST} % MwSt.)`) lines.push('', 'ZAHLUNG', `${t.payTitle} — ${t.payText}`) if (t.hasMonthly) { lines.push('', 'LAUFEND') for (const x of t.monthlyItems) { lines.push(`- ${x.item.n}${x.q > 1 ? ` (${x.q}×)` : ''}: ${x.item.ask ? 'nach Last – rechnet Kenso aus' : `${eur(x.m)} pro Monat`}`) } lines.push(`Summe: ${eur(t.mSum)} pro Monat${t.askServer ? ' zzgl. Serveranteil – rechnet Kenso aus' : ''}`) } lines.push('', 'HINWEIS', `${t.flagTitle} ${t.flagText}`) const term = terminLine() if (term) { lines.push('', 'TERMIN', term) if (state.termNote) lines.push(`Thema: ${state.termNote}`) } if (state.notes) lines.push('', 'NOTIZEN', state.notes) if (state.portalEmail || state.portalPass) { lines.push('', 'KUNDENPORTAL (project.webklar.com)') if (state.portalEmail) lines.push(`E-Mail: ${state.portalEmail}`) if (state.portalPass) lines.push(`Passwort: ${state.portalPass}`) } lines.push('', 'Alle Preise netto zzgl. gesetzlicher Umsatzsteuer.') return lines.join('\n') } /* --------------------------------------------------------------- Rendern --- */ function renderNumbers(t) { el('hdr-net').textContent = eur(t.netNum) el('hdr-hrs').textContent = fmt(t.hrsNum) el('hdr-monthly').textContent = t.hasMonthly ? t.monthly : '–' el('hero-net').textContent = eur(t.netNum) el('hero-sub').textContent = `netto · ${eur(t.grossNum)} brutto inkl. ${MWST} % MwSt.` el('hero-hrs').textContent = `${fmt(t.hrsNum)} Std` el('hero-rate').textContent = `Stundensatz ${eur(t.rateNum)}/Std` el('hero-monthly').textContent = t.hasMonthly ? t.monthly : '–' el('hero-monthly-sub').textContent = t.askServer ? 'zzgl. Serveranteil, rechnet Kenso aus' : (t.hasMonthly ? 'Wartung, Hosting, Backups' : 'nichts Laufendes gewählt') el('pay-title').textContent = t.payTitle el('pay-text').textContent = t.payText const flag = el('flag') flag.className = `flag ${t.tone === 'neutral' ? '' : t.tone}`.trim() flag.innerHTML = `${escapeHtml(t.flagTitle)} ${escapeHtml(t.flagText)}` } function renderTypeChips(t) { el('type-chips').innerHTML = CATS.map((cat) => { const on = Boolean(state.types[cat.id]) const count = t.sel.filter((x) => x.cat.id === cat.id).length return `` }).join('') } function itemHours(cat, item) { const q = item.q ? (state.qty[item.id] || 1) : 1 if (cat.monthly) return item.ask ? 'n. Last' : `${eur((item.m || 0) * q)}/Mon` return `${fmt((item.h || 0) * q)} Std` } function renderItem(cat, item) { const on = Boolean(state.sel[item.id]) const q = state.qty[item.id] || 1 return `
${on ? '✓' : ''} ${escapeHtml(item.n)} ${item.d ? `${escapeHtml(item.d)}` : ''} ${escapeHtml(itemHours(cat, item))} ${item.q ? ` ` : ''}
` } function renderSections() { const visible = CATS.filter((cat) => state.types[cat.id]) el('empty-hint').hidden = visible.length > 0 el('sections').innerHTML = visible.map((cat) => `

${escapeHtml(cat.name)}

${escapeHtml(cat.hint)}

${cat.groups.map((grp) => `
${escapeHtml(grp.g)}
${grp.items.map((item) => renderItem(cat, item)).join('')}
`).join('')}
`).join('') } function renderChosen(t) { el('chosen-panel').hidden = t.sel.length === 0 el('chosen-label').textContent = `Ausgewählt (${t.sel.length})` el('chosen-list').innerHTML = t.sel.map((x) => { const value = x.cat.monthly ? (x.item.ask ? 'n. Last' : `${eur(x.m)}/M`) : `${fmt(x.h)} Std` return `${escapeHtml(x.item.n)}${x.q > 1 ? ` ×${x.q}` : ''}${escapeHtml(value)}` }).join('') } function renderTermin() { el('termin-body').hidden = !state.wantTermin document.querySelectorAll('#want-seg button').forEach((btn) => { btn.classList.toggle('on', (btn.dataset.want === '1') === state.wantTermin) }) if (!state.wantTermin) return el('person-list').innerHTML = Object.keys(PEOPLE).map((id) => { const p = PEOPLE[id] const on = state.person === id return `` }).join('') const days = terminDays() el('days-label').textContent = `Freie Tage — ${PEOPLE[state.person].name}` el('day-list').innerHTML = days.map((d) => { const free = d.slots.length const on = state.day === d.key && free > 0 return `` }).join('') const day = selectedDay() el('slot-wrap').hidden = !day if (day) { el('slots-label').textContent = `Uhrzeit am ${longDay(day.date)}` el('slot-list').innerHTML = day.slots.map((s) => { const on = state.slot === s return `` }).join('') } renderTermStatus() } function renderTermStatus() { const box = el('term-status') const line = terminLine() if (line) { box.className = 'term-status fixed' box.innerHTML = `Termin steht: ${escapeHtml(line)}${state.termNote ? ` — Thema: ${escapeHtml(state.termNote)}` : ''}` } else { box.className = 'term-status' box.innerHTML = 'Noch kein Termin. Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.' } } function render() { const t = totals() renderNumbers(t) renderTypeChips(t) renderSections() renderChosen(t) renderTermin() saveState() } /* --------------------------------------------------------------- Aktionen --- */ function toggleItem(id) { if (!ITEM_BY_ID[id]) return state.sel[id] = !state.sel[id] render() } function changeQty(id, delta) { state.qty[id] = Math.max(1, (state.qty[id] || 1) + delta) if (!state.sel[id]) state.sel[id] = true render() } function setQty(id, value) { const n = Math.max(1, parseInt(value, 10) || 1) state.qty[id] = n render() } function openSummary() { el('summary-text').value = summaryText() el('summary-dialog').hidden = false } function closeSummary() { el('summary-dialog').hidden = true } async function copySummary(btn) { const text = el('summary-text').value let ok = false try { await navigator.clipboard.writeText(text) ok = true } catch { // Fallback fuer Browser ohne Clipboard-API bzw. ohne HTTPS-Kontext const area = el('summary-text') area.removeAttribute('readonly') area.select() ok = document.execCommand('copy') area.setAttribute('readonly', '') } btn.textContent = ok ? 'Kopiert ✓' : 'Bitte manuell markieren' setTimeout(() => { btn.textContent = 'Kopieren' }, 2200) } function resetSelection() { if (!confirm('Alle angekreuzten Positionen zurücksetzen? Notizen und Termin bleiben erhalten.')) return state.sel = {} state.qty = {} render() } /* ------------------------------------------------------------------- Init --- */ function bindEvents() { document.addEventListener('click', (event) => { const target = event.target.closest('[data-act]') if (!target) return const act = target.dataset.act // Klicks auf die Anzahl-Steuerung treffen zuerst deren eigenes data-act, // schalten die Karte also nicht mit um. switch (act) { case 'toggle-type': state.types[target.dataset.id] = !state.types[target.dataset.id] render() break case 'toggle-item': toggleItem(target.dataset.id) break case 'qty': changeQty(target.dataset.id, Number(target.dataset.delta)) break case 'want': state.wantTermin = target.dataset.want === '1' render() break case 'person': state.person = target.dataset.id state.day = '' state.slot = '' render() break case 'day': state.day = target.dataset.key state.slot = '' render() break case 'slot': state.slot = target.dataset.t render() break case 'open-summary': openSummary(); break case 'close-summary': closeSummary(); break case 'copy': copySummary(target); break case 'reset': resetSelection(); break default: break } }) document.addEventListener('change', (event) => { const input = event.target.closest('[data-act="qty-set"]') if (!input) return setQty(input.dataset.id, input.value) }) // Karten sind divs mit role=button -> Tastatur nachruesten. // Im Anzahl-Feld und auf den +/- Buttons darf das nicht greifen. document.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return if (event.target.closest('input, textarea, button')) return const card = event.target.closest('[data-act="toggle-item"]') if (!card) return event.preventDefault() toggleItem(card.dataset.id) }) el('term-note').addEventListener('input', (event) => { state.termNote = event.target.value renderTermStatus() saveState() }) el('notes').addEventListener('input', (event) => { state.notes = event.target.value saveState() }) el('portal-email').addEventListener('input', (event) => { state.portalEmail = event.target.value saveState() }) el('portal-pass').addEventListener('input', (event) => { state.portalPass = event.target.value saveState() }) el('summary-dialog').addEventListener('click', (event) => { if (event.target === el('summary-dialog')) closeSummary() }) document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !el('summary-dialog').hidden) closeSummary() }) } async function initKalkulation() { try { const me = await api('/api/auth/me') if (!me.authenticated || me.role !== 'admin') { window.location.href = '/login.html' return } } catch { window.location.href = '/login.html' return } const customerId = new URLSearchParams(window.location.search).get('customer') || '' if (customerId) { storageKey = `wk-kalkulation:${customerId}` try { const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`) customer = data.customer } catch (err) { console.warn('Kunde konnte nicht geladen werden:', err.message) } } restoreState() if (customer) { const parts = [customer.name, customer.location].filter(Boolean) el('kal-lead').innerHTML = `Lead: ${escapeHtml(parts.join(' · '))}` el('hero-kicker').textContent = `Angebot für ${customer.name}` if (!state.portalEmail) state.portalEmail = customer.email || '' } el('term-note').value = state.termNote el('notes').value = state.notes el('portal-email').value = state.portalEmail el('portal-pass').value = state.portalPass bindEvents() render() } window.initKalkulation = initKalkulation