Der im Gespraech vereinbarte Termin wird mit einem Klick am Akquise-Ticket
eingetragen (startDate/startTime/endTime/assignedTo/assignedName) - genau die
Felder, die das WOMS-Planboard liest. Kein zweiter Datensatz, damit Termin und
Ticket nicht auseinanderlaufen koennen.
- server/services/appointments.js: Ticket des Kunden finden, Termin schreiben,
belegte Zeiten lesen; Umrechnung ISO <-> "dd.mm.yyyy" und "hh:mm" <-> "hhmm"
- Routen GET /api/portal-admin/appointments und
POST /api/portal-admin/customers/:id/appointment
- Bereits vergebene Zeiten sind in der Terminauswahl gesperrt ("belegt"), der
eigene Termin bleibt waehlbar; steht am Ticket schon einer, ist er beim
Oeffnen vorausgewaehlt
- Zeiten, die jemand von Hand in WOMS gesetzt hat, tauchen mit auf
Sicherung gegen Datenverlust: bevorzugt wird das Akquise-Ticket. Gibt es keins,
kommt nur ein anderes offenes Ticket OHNE Datum in Frage - bei Webpage- und
Projekttickets bedeutet startDate den Projektstart, den wuerden wir sonst still
ueberschreiben. Die Rueckmeldung nennt immer Nummer und Art des Tickets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
837 lines
30 KiB
JavaScript
837 lines
30 KiB
JavaScript
/* 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',
|
||
email: 'justin@webklar.com',
|
||
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',
|
||
email: 'kenso@webklar.com',
|
||
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'
|
||
|
||
/* Schon vergebene Termine (aus den Akquise-Tickets), damit nichts doppelt
|
||
gebucht wird. Wird beim Laden und nach jedem Speichern aktualisiert. */
|
||
let booked = []
|
||
let savedAppointment = null
|
||
|
||
/* --------------------------------------------------------------- Helfer --- */
|
||
function escapeHtml(str) {
|
||
return String(str ?? '')
|
||
.replace(/&/g, '&')
|
||
.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, options = {}) {
|
||
const response = await fetch(path, {
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...options,
|
||
})
|
||
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 --- */
|
||
/* Feste Zeitfenster der Person, ergaenzt um Zeiten, die in WOMS bereits am
|
||
Ticket stehen (jemand kann dort auch von Hand einen Termin setzen). */
|
||
function slotsFor(personId, date) {
|
||
const base = PEOPLE[personId].slots[date.getDay()] || []
|
||
const key = dayKey(date)
|
||
const email = PEOPLE[personId].email
|
||
const extra = booked
|
||
.filter((a) => a.date === key && a.employeeEmail === email && a.time && !base.includes(a.time))
|
||
.map((a) => a.time)
|
||
return extra.length ? [...new Set([...base, ...extra])].sort() : base
|
||
}
|
||
|
||
function dayKey(date) {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
/* Ist die Zeit beim aktuell gewaehlten Mitarbeiter schon vergeben?
|
||
'own' = der Termin dieses Kunden (der darf natuerlich stehen bleiben). */
|
||
function slotTaken(dateKey, time) {
|
||
const email = PEOPLE[state.person].email
|
||
const hit = booked.find((a) => a.date === dateKey && a.time === time && a.employeeEmail === email)
|
||
if (!hit) return null
|
||
return hit.customerId && customer && hit.customerId === customer.id ? 'own' : 'other'
|
||
}
|
||
|
||
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)
|
||
const key = dayKey(d)
|
||
const slots = slotsFor(state.person, d)
|
||
list.push({
|
||
key,
|
||
date: d,
|
||
slots,
|
||
free: slots.filter((s) => slotTaken(key, s) !== 'other'),
|
||
})
|
||
}
|
||
return list
|
||
}
|
||
|
||
async function loadAppointments() {
|
||
const days = terminDays()
|
||
if (!days.length) return
|
||
try {
|
||
const { appointments } = await api(
|
||
`/api/portal-admin/appointments?from=${days[0].key}&to=${days[days.length - 1].key}`
|
||
)
|
||
booked = appointments || []
|
||
} catch (err) {
|
||
console.warn('Belegte Termine konnten nicht geladen werden:', err.message)
|
||
booked = []
|
||
}
|
||
}
|
||
|
||
function selectedDay() {
|
||
return terminDays().find((d) => d.key === state.day && d.free.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 (savedAppointment?.woid) lines.push(`Eingetragen in Ticket #${savedAppointment.woid}`)
|
||
}
|
||
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.free.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 taken = slotTaken(day.key, s)
|
||
const on = state.slot === s
|
||
const cls = ['slot', on ? 'on' : '', taken === 'own' && !on ? 'taken-own' : ''].filter(Boolean).join(' ')
|
||
const label = taken === 'other' ? `${s} · belegt` : (taken === 'own' && !on ? `${s} · dieser Kunde` : s)
|
||
return `<button type="button" class="${cls}" data-act="slot" data-t="${s}"
|
||
${taken === 'other' ? 'disabled' : ''} aria-pressed="${on}">${label}</button>`
|
||
}).join('')
|
||
}
|
||
|
||
renderTermStatus()
|
||
renderSaveButton()
|
||
}
|
||
|
||
function renderSaveButton() {
|
||
const btn = el('term-save')
|
||
const hint = el('term-save-hint')
|
||
if (!btn) return
|
||
const ready = Boolean(terminLine()) && Boolean(customer)
|
||
btn.disabled = !ready || btn.dataset.busy === '1'
|
||
|
||
if (hint.dataset.sticky === '1') return
|
||
hint.className = 'term-save-hint'
|
||
if (!customer) {
|
||
hint.textContent = 'Ohne Kunde kann kein Ticket beschrieben werden — Seite über einen Lead öffnen.'
|
||
} else if (!ready) {
|
||
hint.textContent = 'Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.'
|
||
} else {
|
||
hint.textContent = `Trägt den Termin am Akquise-Ticket von ${customer.name} ein — damit steht er im Planboard.`
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
/* Steht am Akquise-Ticket schon ein Termin, ist das die Wahrheit - der
|
||
Zwischenspeicher im Browser hat dann das Nachsehen. */
|
||
function adoptExistingAppointment() {
|
||
const existing = booked.find((a) => a.customerId === customer.id)
|
||
if (!existing) return
|
||
savedAppointment = existing
|
||
|
||
const personId = Object.keys(PEOPLE).find((id) => PEOPLE[id].email === existing.employeeEmail)
|
||
if (!personId) return
|
||
|
||
state.wantTermin = true
|
||
state.person = personId
|
||
state.day = existing.date
|
||
state.slot = existing.time
|
||
}
|
||
|
||
function clearSaveHint() {
|
||
const hint = el('term-save-hint')
|
||
if (hint) delete hint.dataset.sticky
|
||
}
|
||
|
||
/* Termin am Akquise-Ticket eintragen -> erscheint im WOMS-Planboard. */
|
||
async function saveTermin() {
|
||
const day = selectedDay()
|
||
if (!day || !state.slot || !customer) return
|
||
|
||
const btn = el('term-save')
|
||
const hint = el('term-save-hint')
|
||
btn.dataset.busy = '1'
|
||
btn.disabled = true
|
||
hint.dataset.sticky = '1'
|
||
hint.className = 'term-save-hint'
|
||
hint.textContent = 'Wird eingetragen…'
|
||
|
||
try {
|
||
const { appointment } = await api(`/api/portal-admin/customers/${encodeURIComponent(customer.id)}/appointment`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ date: day.key, time: state.slot, employeeEmail: PEOPLE[state.person].email }),
|
||
})
|
||
savedAppointment = appointment
|
||
hint.className = 'term-save-hint done'
|
||
const art = appointment.type && appointment.type.toLowerCase() !== 'akquise' ? `${appointment.type}-Ticket` : 'Ticket'
|
||
hint.textContent = `Eingetragen in ${art} ${appointment.woid ? `#${appointment.woid}` : ''} – steht jetzt im Planboard.`
|
||
await loadAppointments()
|
||
} catch (err) {
|
||
hint.className = 'term-save-hint error'
|
||
hint.textContent = err.message
|
||
} finally {
|
||
delete btn.dataset.busy
|
||
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'
|
||
clearSaveHint()
|
||
render()
|
||
break
|
||
case 'person':
|
||
state.person = target.dataset.id
|
||
state.day = ''
|
||
state.slot = ''
|
||
clearSaveHint()
|
||
render()
|
||
break
|
||
case 'day':
|
||
state.day = target.dataset.key
|
||
state.slot = ''
|
||
clearSaveHint()
|
||
render()
|
||
break
|
||
case 'slot':
|
||
state.slot = target.dataset.t
|
||
clearSaveHint()
|
||
render()
|
||
break
|
||
case 'save-termin':
|
||
saveTermin()
|
||
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()
|
||
await loadAppointments()
|
||
|
||
if (customer) {
|
||
el('hero-kicker').textContent = `Angebot für ${customer.name}`
|
||
if (!state.portalEmail) state.portalEmail = customer.email || ''
|
||
adoptExistingAppointment()
|
||
}
|
||
|
||
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
|