Rechnungsanalyse: Kalkulationsseite fuer Leads im Admin-Panel

Bei Leads (customerStatus=lead) steht in der Kundenliste jetzt der Button
"Rechnungsanalyse". Er oeffnet /kalkulation.html?customer=<id> - die Seite,
die im Gespraech vor Ort oder am Telefon ausgefuellt wird.

- Positionen nach Bereich (Website, Onlineshop, Softwareprojekt,
  Automatisierung/KI, laufende Kosten) mit Stundenwerten und Mengen
- Preis ueber die Stundenstaffel (150/130/115/100), Brutto mit 19 % MwSt.,
  Zahlungsmodalitaeten ab 1.000 / 3.000 EUR
- Hinweis, ob selbst zusagbar (<= 5 Std), Termin abzustimmen oder ob Kenso
  freigeben muss (Shop, Software, Automatisierung)
- Termin mit Justin/Kenso aus den hinterlegten Zeitfenstern, Notizen,
  Kundenportal-Zugang mit QR-Code
- Zusammenfassung als Text zum Kopieren -> gehoert ins Akquise-Ticket

Bewusst ohne eigenen Datensatz: dokumentiert wird im Ticket. Der Stand liegt
pro Kunde nur im localStorage, damit ein angefangenes Gespraech nicht
verloren geht.

Fonts (Caprasimo, Figtree) liegen lokal unter public/fonts, kein Abruf beim
Google-CDN. Neue Route GET /api/portal-admin/customers/:customerId liefert
die Stammdaten zum Vorbefuellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-08 21:42:13 +00:00
parent a4b4974431
commit 9108c90a53
11 changed files with 1269 additions and 1 deletions

707
public/kalkulation.js Normal file
View File

@@ -0,0 +1,707 @@
/* Rechnungsanalyse / Projektkalkulation.
Wird aus dem Admin-Panel bei einem Lead geoeffnet (kalkulation.html?customer=<id>)
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 = `<b>${escapeHtml(t.flagTitle)}</b> ${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 `<button type="button" class="chip${on ? ' on' : ''}" data-act="toggle-type" data-id="${cat.id}" aria-pressed="${on}">
<span class="mark">${on ? '✓' : ''}</span>${escapeHtml(cat.name)}${count ? `<span class="count">${count}</span>` : ''}
</button>`
}).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 `<div class="item${on ? ' on' : ''}" role="button" tabindex="0" aria-pressed="${on}" data-act="toggle-item" data-id="${item.id}">
<span class="mark">${on ? '✓' : ''}</span>
<span class="item-body">
<b class="item-name">${escapeHtml(item.n)}</b>
${item.d ? `<span class="item-desc">${escapeHtml(item.d)}</span>` : ''}
<span class="item-hrs">${escapeHtml(itemHours(cat, item))}</span>
${item.q ? `<span class="qty">
<button type="button" data-act="qty" data-id="${item.id}" data-delta="-1" aria-label="Anzahl verringern"></button>
<input type="number" min="1" step="1" value="${q}" data-act="qty-set" data-id="${item.id}" aria-label="Anzahl">
<button type="button" data-act="qty" data-id="${item.id}" data-delta="1" aria-label="Anzahl erhöhen">+</button>
</span>` : ''}
</span>
</div>`
}
function renderSections() {
const visible = CATS.filter((cat) => state.types[cat.id])
el('empty-hint').hidden = visible.length > 0
el('sections').innerHTML = visible.map((cat) => `
<section class="block">
<div class="section-head">
<h2>${escapeHtml(cat.name)}</h2>
<span class="section-tag${cat.kenso ? ' kenso' : ''}">${cat.kenso ? 'Kenso gibt frei' : 'darfst du selbst'}</span>
</div>
<p class="block-lead">${escapeHtml(cat.hint)}</p>
${cat.groups.map((grp) => `
<div class="group-label">${escapeHtml(grp.g)}</div>
<div class="item-grid">${grp.items.map((item) => renderItem(cat, item)).join('')}</div>
`).join('')}
</section>`).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 `<span class="chosen-chip">${escapeHtml(x.item.n)}${x.q > 1 ? ` ×${x.q}` : ''}<b>${escapeHtml(value)}</b></span>`
}).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 `<button type="button" class="person${on ? ' on' : ''}" data-act="person" data-id="${id}" aria-pressed="${on}">
<span class="ava">${p.initial}</span>
<span><b>${escapeHtml(p.name)}</b><span class="role">${escapeHtml(p.role)}</span></span>
</button>`
}).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 `<button type="button" class="day${on ? ' on' : ''}" data-act="day" data-key="${d.key}"${free ? '' : ' disabled'}>
<span class="wd">${d.date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}</span>
<span class="dm">${d.date.getDate()}.${d.date.getMonth() + 1}.</span>
<span class="free">${free ? `${free} frei` : '—'}</span>
</button>`
}).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 `<button type="button" class="slot${on ? ' on' : ''}" data-act="slot" data-t="${s}" aria-pressed="${on}">${s}</button>`
}).join('')
}
renderTermStatus()
}
function renderTermStatus() {
const box = el('term-status')
const line = terminLine()
if (line) {
box.className = 'term-status fixed'
box.innerHTML = `<b>Termin steht:</b> ${escapeHtml(line)}${state.termNote ? ` — Thema: ${escapeHtml(state.termNote)}` : ''}`
} else {
box.className = 'term-status'
box.innerHTML = '<b>Noch kein Termin.</b> 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 =
`<span style="font-size:13.5px;color:var(--color-accent-2-100)">Lead: <b style="color:#fff">${escapeHtml(parts.join(' · '))}</b></span>`
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