Files
Webklar-Kundenbereich/public/kalkulation.js
Claude c4a6c90306 Rechnungsanalyse: Kopfzeile aufgeraeumt, Zurueck-Weg steckt im Logo
Der Button "← Admin" und die Lead-Zeile in der Kopfleiste sind raus. Das
webklar-Logo ist jetzt selbst der Link zurueck ins Admin-Panel (mit title
als Hinweis). Der Lead steht weiterhin ueber dem Preis ("Angebot für …").
2026-08-08 21:47:31 +00:00

705 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) {
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