diff --git a/public/admin.html b/public/admin.html
index 5cf8cd0..45f486a 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -38,6 +38,7 @@
.mini-btn { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.18); color: inherit;
padding: 5px 11px; border-radius: 8px; cursor: pointer; font-size: 13px; white-space: nowrap; }
.mini-btn:hover { background: rgba(255,255,255,0.14); }
+ a.mini-btn { display: inline-flex; align-items: center; text-decoration: none; }
.mini-btn.primary { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
.code-input { width: 100%; min-width: 60px; max-width: 90px; padding: 4px 8px; border-radius: 7px; border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 13px; }
diff --git a/public/admin.js b/public/admin.js
index c702c4e..e08df62 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -41,6 +41,10 @@ function fmtEuro(value) {
return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
}
+function isLead(customer) {
+ return String(customer.customerStatus || '').toLowerCase() === 'lead'
+}
+
function stagePill(status) {
const s = String(status || '').toLowerCase()
if (s === 'lead') return 'Lead'
@@ -233,7 +237,10 @@ async function loadCustomers(search = '') {
${c.projectCount} |
-
+ ${isLead(c) ? ` Rechnungsanalyse` : ''}
+
diff --git a/public/fonts/caprasimo-latin-ext.woff2 b/public/fonts/caprasimo-latin-ext.woff2
new file mode 100644
index 0000000..e83e8d8
Binary files /dev/null and b/public/fonts/caprasimo-latin-ext.woff2 differ
diff --git a/public/fonts/caprasimo-latin.woff2 b/public/fonts/caprasimo-latin.woff2
new file mode 100644
index 0000000..a1e50c3
Binary files /dev/null and b/public/fonts/caprasimo-latin.woff2 differ
diff --git a/public/fonts/figtree-latin-ext.woff2 b/public/fonts/figtree-latin-ext.woff2
new file mode 100644
index 0000000..a8a1123
Binary files /dev/null and b/public/fonts/figtree-latin-ext.woff2 differ
diff --git a/public/fonts/figtree-latin.woff2 b/public/fonts/figtree-latin.woff2
new file mode 100644
index 0000000..05ce657
Binary files /dev/null and b/public/fonts/figtree-latin.woff2 differ
diff --git a/public/img/portal-qr.png b/public/img/portal-qr.png
new file mode 100644
index 0000000..d35c9dc
Binary files /dev/null and b/public/img/portal-qr.png differ
diff --git a/public/kalkulation.css b/public/kalkulation.css
new file mode 100644
index 0000000..73cbbfb
--- /dev/null
+++ b/public/kalkulation.css
@@ -0,0 +1,362 @@
+/* Rechnungsanalyse / Projektkalkulation.
+ Bewusst eigenes helles "Organic"-Design (nicht das dunkle Portal-Theme),
+ weil die Seite im Kundengespraech gezeigt wird. Fonts liegen lokal unter
+ /fonts (kein Google-CDN-Abruf). */
+
+@font-face {
+ font-family: 'Caprasimo'; font-style: normal; font-weight: 400; font-display: swap;
+ src: url(/fonts/caprasimo-latin-ext.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Caprasimo'; font-style: normal; font-weight: 400; font-display: swap;
+ src: url(/fonts/caprasimo-latin.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* Figtree ist ein Variable Font – eine Datei deckt 400–700 ab. */
+@font-face {
+ font-family: 'Figtree'; font-style: normal; font-weight: 400 700; font-display: swap;
+ src: url(/fonts/figtree-latin-ext.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Figtree'; font-style: normal; font-weight: 400 700; font-display: swap;
+ src: url(/fonts/figtree-latin.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+:root {
+ --color-bg: #f5ead8;
+ --color-surface: #ebddc5;
+ --color-text: #201e1d;
+ --color-accent: #c67139;
+ --color-accent-2: #7a8a5e;
+
+ --color-neutral-100: #f9f4ed;
+ --color-neutral-200: #eee7db;
+ --color-neutral-300: #dcd3c4;
+ --color-neutral-400: #c0b6a5;
+ --color-neutral-500: #a19786;
+ --color-neutral-600: #82796a;
+ --color-neutral-700: #645c50;
+ --color-neutral-800: #474238;
+ --color-neutral-900: #2e2b25;
+
+ --color-accent-100: #fff2eb;
+ --color-accent-200: #ffe1d0;
+ --color-accent-700: #8c491a;
+ --color-accent-800: #643312;
+
+ --color-accent-2-100: #f0fae1;
+ --color-accent-2-200: #e1eecc;
+ --color-accent-2-500: #8fa073;
+ --color-accent-2-600: #728157;
+ --color-accent-2-700: #56633f;
+ --color-accent-2-800: #3d472b;
+
+ --font-heading: 'Caprasimo', Georgia, serif;
+ --font-body: 'Figtree', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+
+ --radius-md: 16px;
+ --radius-lg: 28px;
+ --shadow-sm: 0 1px 2px rgba(46, 43, 37, 0.14);
+ --shadow-md: 0 3px 10px rgba(46, 43, 37, 0.16);
+ --shadow-lg: 0 12px 32px rgba(46, 43, 37, 0.22);
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ font-size: 15px;
+ line-height: 1.55;
+}
+
+h1, h2, h3 { font-family: var(--font-heading); font-weight: 400; line-height: 1.12; margin: 0; }
+a { color: var(--color-accent-700); }
+a:hover { color: var(--color-accent-800); }
+button { font: inherit; }
+:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+::selection { background: var(--color-accent-200); }
+input[type=number]::-webkit-outer-spin-button,
+input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
+input[type=number] { -moz-appearance: textfield; }
+
+/* ------------------------------------------------------------- Kopfzeile --- */
+.kal-header {
+ position: sticky; top: 0; z-index: 20;
+ background: var(--color-accent-2-500); color: var(--color-accent-2-100);
+ padding: 14px 20px; box-shadow: var(--shadow-md);
+}
+.kal-header-inner {
+ max-width: 1040px; margin: 0 auto;
+ display: flex; align-items: center; gap: clamp(10px, 2.4vw, 18px); flex-wrap: wrap;
+}
+.kal-brand { font-family: var(--font-heading); font-size: clamp(17px, 4vw, 22px); color: #fff; }
+.kal-back {
+ color: var(--color-accent-2-100); text-decoration: none; font-size: 13.5px; font-weight: 600;
+ border: 1px solid rgba(255, 255, 255, 0.4); border-radius: 999px; padding: 5px 14px; white-space: nowrap;
+}
+.kal-back:hover { background: rgba(255, 255, 255, 0.16); color: #fff; }
+.kal-spacer { flex: 1; min-width: 0; }
+.kal-stat { display: flex; align-items: baseline; gap: 8px; }
+.kal-stat-lbl {
+ font-size: 11.5px; font-weight: 700; letter-spacing: 0.14em;
+ text-transform: uppercase; color: var(--color-accent-2-200);
+}
+.kal-stat-val { font-family: var(--font-heading); line-height: 1; color: #fff; font-size: clamp(16px, 3.6vw, 22px); }
+.kal-stat-val.big { font-size: clamp(19px, 4.6vw, 30px); }
+
+main { max-width: 1040px; margin: 0 auto; padding: clamp(18px, 3.4vw, 28px) clamp(12px, 3.2vw, 20px) 100px; }
+
+/* ------------------------------------------------------------------ Hero --- */
+.hero {
+ border-radius: var(--radius-lg); background: var(--color-accent-2-500);
+ color: var(--color-accent-2-100);
+ padding: clamp(20px, 4vw, 34px) clamp(20px, 4vw, 34px) clamp(20px, 3.6vw, 30px);
+}
+.kicker { font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--color-accent-2-200); }
+.hero-price-row { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-top: 10px; }
+.hero-price { font-family: var(--font-heading); font-size: clamp(46px, 11vw, 72px); line-height: 0.95; color: #fff; }
+.hero-price-sub { font-size: 15px; color: var(--color-accent-2-200); padding-bottom: 12px; }
+.hero-tiles {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(clamp(120px, 44%, 210px), 1fr));
+ gap: 10px; margin-top: 24px;
+}
+.tile { background: rgba(255, 255, 255, 0.16); border-radius: var(--radius-md); padding: 16px 18px; }
+.tile-lbl { font-size: 11.5px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-accent-2-200); }
+.tile-val { font-family: var(--font-heading); font-size: 30px; color: #fff; margin-top: 4px; }
+.tile-sub { font-size: 12.5px; color: var(--color-accent-2-200); margin-top: 2px; }
+.tile-pay { grid-column: 1 / -1; background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
+.tile-pay .tile-lbl { color: var(--color-accent-2-600); }
+.tile-pay-title { font-weight: 700; font-size: 16px; margin-top: 4px; }
+.tile-pay-text { font-size: 13px; line-height: 1.45; margin-top: 4px; text-wrap: pretty; }
+.flag {
+ margin-top: 12px; padding: 15px 18px; border-radius: var(--radius-md);
+ font-size: 13.5px; line-height: 1.5; text-wrap: pretty;
+ background: rgba(255, 255, 255, 0.16); color: var(--color-accent-2-100);
+}
+.flag.ok { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
+.flag.talk { background: var(--color-accent-200); color: var(--color-accent-800); }
+.flag.kenso { background: var(--color-neutral-100); color: var(--color-neutral-800); }
+.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
+.btn-solid {
+ padding: 14px 26px; border-radius: 999px; border: none; background: #fff;
+ color: var(--color-accent-2-700); font-weight: 700; font-size: 15px; cursor: pointer;
+}
+.btn-outline {
+ padding: 14px 22px; border-radius: 999px; border: 2px solid rgba(255, 255, 255, 0.4);
+ background: transparent; color: var(--color-accent-2-100); font-weight: 700; font-size: 15px; cursor: pointer;
+}
+.btn-outline:hover { background: rgba(255, 255, 255, 0.14); }
+
+/* -------------------------------------------------------------- Bausteine --- */
+.block { margin-top: 38px; }
+.block h2 { font-size: clamp(23px, 5.4vw, 32px); }
+.block-lead { font-size: 14.5px; color: var(--color-neutral-600); margin: 4px 0 14px; max-width: 620px; text-wrap: pretty; }
+.section-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
+.section-head h2 { flex: 1; min-width: 150px; }
+.section-tag {
+ font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
+ padding: 6px 13px; border-radius: 999px;
+ background: var(--color-accent-200); color: var(--color-accent-800);
+}
+.section-tag.kenso { background: var(--color-accent-2-200); color: var(--color-accent-2-800); }
+.group-label {
+ font-size: 11.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
+ color: var(--color-neutral-500); margin: 22px 0 10px;
+}
+.chips { display: flex; flex-wrap: wrap; gap: 10px; }
+
+.chip {
+ display: inline-flex; align-items: center; gap: 9px; padding: 12px 20px; border-radius: 999px;
+ border: 2px solid var(--color-neutral-300); background: transparent;
+ color: var(--color-neutral-700); font-weight: 700; font-size: 15px; cursor: pointer; min-height: 48px;
+}
+.chip.on { border-color: var(--color-accent); background: var(--color-accent-100); color: var(--color-accent-800); }
+.chip .mark {
+ flex: 0 0 20px; height: 20px; border-radius: 999px; border: 2px solid var(--color-neutral-400);
+ display: flex; align-items: center; justify-content: center; color: #fff; font-size: 11px;
+}
+.chip.on .mark { border-color: var(--color-accent); background: var(--color-accent); }
+.chip .count {
+ font-weight: 700; font-size: 13px; padding: 2px 9px; border-radius: 999px;
+ background: var(--color-accent-200); color: var(--color-accent-800);
+}
+
+.item-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(clamp(130px, 45%, 300px), 1fr)); gap: 10px; }
+.item {
+ display: flex; align-items: flex-start; gap: clamp(9px, 2vw, 14px);
+ padding: clamp(13px, 2.6vw, 18px) clamp(14px, 2.8vw, 20px);
+ border-radius: var(--radius-lg); border: 2px solid var(--color-neutral-200);
+ background: var(--color-neutral-100); cursor: pointer; min-height: 72px; text-align: left;
+ color: inherit; width: 100%;
+}
+.item.on { border-color: var(--color-accent); background: var(--color-accent-100); }
+.item .mark {
+ flex: 0 0 clamp(20px, 5vw, 26px); width: clamp(20px, 5vw, 26px); height: clamp(20px, 5vw, 26px);
+ border-radius: 999px; border: 2px solid var(--color-neutral-400);
+ display: flex; align-items: center; justify-content: center;
+ color: #fff; font-size: 13px; font-weight: 700; margin-top: 2px;
+}
+.item.on .mark { border-color: var(--color-accent); background: var(--color-accent); }
+.item-body { flex: 1; min-width: 0; }
+.item-name { display: block; font-weight: 600; font-size: clamp(14px, 2.9vw, 16px); line-height: 1.3; }
+.item-desc {
+ display: block; font-size: clamp(11.5px, 2.4vw, 13px); line-height: 1.45;
+ color: var(--color-neutral-600); margin-top: 4px; text-wrap: pretty;
+}
+.item-hrs {
+ display: inline-block; margin-top: 9px; padding: 4px 11px; border-radius: 999px;
+ background: var(--color-neutral-200); color: var(--color-neutral-600);
+ font-size: 12.5px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap;
+}
+.item.on .item-hrs { background: var(--color-accent-200); color: var(--color-accent-800); }
+.qty { display: flex; align-items: center; gap: 6px; margin-top: 10px; max-width: 100%; }
+.qty button {
+ flex: 0 0 auto; width: clamp(34px, 9vw, 44px); height: 44px; border-radius: 999px;
+ border: 2px solid var(--color-neutral-300); background: #fff; color: var(--color-accent-700);
+ font-size: 20px; font-weight: 700; cursor: pointer; line-height: 1; padding: 0;
+}
+.qty input {
+ flex: 1 1 auto; min-width: 0; width: clamp(44px, 13vw, 64px); height: 44px; border-radius: 999px;
+ border: 2px solid var(--color-neutral-300); background: #fff; font: inherit; font-size: 15px;
+ font-weight: 700; text-align: center; color: var(--color-text); padding: 0;
+}
+
+.empty-hint {
+ margin-top: 34px; padding: 34px; border-radius: var(--radius-lg);
+ background: var(--color-neutral-100); border: 2px dashed var(--color-neutral-300); text-align: center;
+}
+.empty-hint h3 { font-size: 24px; margin-bottom: 6px; }
+.empty-hint p { font-size: 14.5px; color: var(--color-neutral-600); margin: 0; }
+
+.panel { margin-top: 38px; border-radius: var(--radius-lg); background: var(--color-surface); padding: clamp(20px, 3.6vw, 30px); }
+.panel h2 { font-size: clamp(23px, 5.4vw, 30px); margin: 6px 0 4px; }
+.panel-lead { font-size: 14.5px; line-height: 1.55; color: var(--color-neutral-700); margin: 0 0 16px; max-width: 560px; text-wrap: pretty; }
+.chosen-chip {
+ display: inline-flex; align-items: center; gap: 10px; padding: 9px 16px; border-radius: 999px;
+ background: var(--color-neutral-100); font-size: 14px;
+}
+.chosen-chip b { color: var(--color-accent-700); font-weight: 700; }
+
+/* ---------------------------------------------------------------- Termin --- */
+.seg { display: inline-flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--color-neutral-200); margin-bottom: 18px; }
+.seg button {
+ padding: 11px 22px; border-radius: 999px; border: none; background: transparent;
+ color: var(--color-neutral-700); font-weight: 700; font-size: 14.5px; cursor: pointer; min-height: 44px;
+}
+.seg button.on { background: var(--color-accent); color: #fff; }
+.person {
+ display: flex; align-items: center; gap: 12px; padding: 12px 20px 12px 12px; border-radius: 999px;
+ border: 2px solid var(--color-neutral-200); background: var(--color-neutral-100);
+ color: var(--color-text); cursor: pointer; min-height: 52px; text-align: left;
+}
+.person.on { border-color: var(--color-accent); background: var(--color-accent-100); }
+.person .ava {
+ flex: 0 0 34px; height: 34px; border-radius: 999px; background: var(--color-neutral-400); color: #fff;
+ display: flex; align-items: center; justify-content: center; font-family: var(--font-heading); font-size: 15px;
+}
+.person.on .ava { background: var(--color-accent); }
+.person b { display: block; font-weight: 700; font-size: 15px; }
+.person span.role { display: block; font-size: 12.5px; color: var(--color-neutral-600); }
+.day-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(clamp(74px, 22%, 110px), 1fr)); gap: 8px; }
+.day {
+ padding: 12px 6px; border-radius: var(--radius-md); border: 2px solid var(--color-neutral-200);
+ background: var(--color-neutral-100); color: var(--color-text); cursor: pointer; min-height: 74px;
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
+}
+.day.on { border-color: var(--color-accent); background: var(--color-accent); color: #fff; }
+.day:disabled { background: transparent; color: var(--color-neutral-500); opacity: 0.45; cursor: default; }
+.day .wd { font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
+.day .dm { font-family: var(--font-heading); font-size: 20px; line-height: 1.1; }
+.day .free { font-size: 11px; font-weight: 600; }
+.slot {
+ padding: 12px 22px; border-radius: 999px; border: 2px solid var(--color-neutral-200);
+ background: var(--color-neutral-100); color: var(--color-text);
+ font-weight: 700; font-size: 15px; cursor: pointer; min-height: 48px;
+}
+.slot.on { border-color: var(--color-accent); background: var(--color-accent); color: #fff; }
+.field-label {
+ display: block; font-size: 11.5px; font-weight: 700; letter-spacing: 0.14em;
+ text-transform: uppercase; color: var(--color-neutral-500); margin-bottom: 8px;
+}
+textarea.kal-input, input.kal-input {
+ width: 100%; border-radius: var(--radius-md); border: 2px solid var(--color-neutral-300);
+ background: var(--color-neutral-100); padding: 14px 16px; font: inherit; font-size: 15px;
+ line-height: 1.5; color: var(--color-text); resize: vertical;
+}
+textarea.kal-input { min-height: 84px; }
+.term-status {
+ margin-top: 16px; padding: 15px 18px; border-radius: var(--radius-md);
+ font-size: 14.5px; line-height: 1.5; text-wrap: pretty;
+ background: var(--color-neutral-100); color: var(--color-neutral-700);
+}
+.term-status.fixed { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
+
+/* ------------------------------------------------------------ Kundenportal --- */
+.portal-box {
+ margin-top: 38px; border-radius: var(--radius-lg); background: var(--color-neutral-100);
+ border: 2px solid var(--color-neutral-200); padding: clamp(18px, 3.4vw, 28px) clamp(18px, 3.6vw, 30px);
+ display: flex; gap: clamp(16px, 3vw, 30px); align-items: center; flex-wrap: wrap;
+}
+.portal-qr { flex: 0 0 auto; background: #fff; border-radius: var(--radius-md); padding: clamp(10px, 2vw, 14px); box-shadow: var(--shadow-sm); }
+.portal-qr img { display: block; width: clamp(104px, 26vw, 180px); height: clamp(104px, 26vw, 180px); }
+.portal-info { flex: 1; min-width: 190px; }
+.portal-info h2 { font-size: 28px; margin: 6px 0 8px; }
+.cred-row { display: flex; flex-wrap: wrap; gap: 10px; }
+.cred {
+ background: #fff; border-radius: 999px; padding: 8px 20px;
+ display: flex; align-items: center; gap: 10px; flex: 1 1 240px;
+}
+.cred span {
+ font-size: 11px; font-weight: 700; letter-spacing: 0.12em;
+ text-transform: uppercase; color: var(--color-neutral-500); white-space: nowrap;
+}
+.cred input {
+ flex: 1; min-width: 0; border: none; background: transparent; font: inherit;
+ font-size: 15.5px; font-weight: 700; color: var(--color-text); padding: 4px 0;
+}
+.cred input.mono { color: var(--color-accent-700); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+
+footer { margin-top: 34px; font-size: 12.5px; color: var(--color-neutral-500); line-height: 1.6; }
+
+/* ---------------------------------------------------------------- Dialog --- */
+.backdrop {
+ position: fixed; inset: 0; z-index: 50; background: rgba(32, 30, 29, 0.55);
+ display: flex; align-items: center; justify-content: center; padding: 20px;
+}
+.backdrop[hidden] { display: none; }
+.dialog {
+ width: 100%; max-width: 640px; max-height: 84vh; overflow: auto; background: var(--color-bg);
+ border-radius: var(--radius-lg); padding: 26px 28px; box-shadow: var(--shadow-lg);
+}
+.dialog h2 { font-size: 26px; margin-bottom: 12px; }
+.dialog textarea {
+ width: 100%; height: 340px; border-radius: var(--radius-md); border: 2px solid var(--color-neutral-300);
+ background: var(--color-neutral-100); padding: 16px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.55;
+ color: var(--color-text); resize: vertical;
+}
+.dialog-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
+.btn-accent {
+ padding: 13px 24px; border-radius: 999px; border: none; background: var(--color-accent);
+ color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
+}
+.btn-quiet {
+ padding: 13px 22px; border-radius: 999px; border: 2px solid var(--color-neutral-300);
+ background: transparent; color: var(--color-neutral-700); font-weight: 700; font-size: 15px; cursor: pointer;
+}
+
+[hidden] { display: none !important; }
+
+@media print {
+ .kal-header, .hero-actions, .seg, .qty, .backdrop { display: none !important; }
+ body { background: #fff; }
+ .hero { color: #201e1d; background: var(--color-accent-2-100); }
+ .hero-price, .tile-val, .kal-brand { color: var(--color-accent-2-800); }
+}
diff --git a/public/kalkulation.html b/public/kalkulation.html
new file mode 100644
index 0000000..46eeea0
--- /dev/null
+++ b/public/kalkulation.html
@@ -0,0 +1,169 @@
+
+
+
+
+
+ Rechnungsanalyse – Webklar
+
+
+
+
+
+
+
+
+ Ihr Projekt
+
+ 0 €
+ netto · 0 € brutto inkl. 19 % MwSt.
+
+
+
+ Aufwand
+ 0,00 Std
+ Stundensatz 150 €/Std
+
+
+ Monatlich
+ –
+ nichts Laufendes gewählt
+
+
+ Zahlung
+ Noch nichts gewählt
+ Kreuze an, was der Kunde braucht.
+
+
+ Noch offen. Sobald etwas gewählt ist, steht hier, ob du selbst abschließen kannst.
+
+
+
+
+
+
+
+
+ Worum geht es?
+ Bereiche antippen — nur die ausgewählten werden unten angezeigt.
+
+
+
+
+
+
+
+ Noch kein Bereich gewählt
+ Tippe oben an, worum es geht — dann erscheinen hier die Positionen.
+
+
+
+
+
+
+
+ Termin
+ Gespräch festlegen
+ Nur wenn gewünscht: Mit wem, wann — und worum es geht. Der Termin hängt an der Zusammenfassung.
+
+
+
+
+
+
+
+
+
+ Freie Tage
+
+
+
+
+
+
+
+
+
+
+ Noch kein Termin. Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
+
+
+
+
+
+
+
+
+
+
+
+
+ 
+
+
+ Kundenportal
+ Code scannen und einloggen
+ Im Portal sieht der Kunde den Stand des Projekts, alle Dateien und die Rechnungen —
+ project.webklar.com
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Zusammenfassung
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/kalkulation.js b/public/kalkulation.js
new file mode 100644
index 0000000..1b11112
--- /dev/null
+++ b/public/kalkulation.js
@@ -0,0 +1,707 @@
+/* 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)}
+ ${cat.kenso ? 'Kenso gibt frei' : 'darfst du selbst'}
+
+ ${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
diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js
index 0fcc2c3..a5aee9b 100644
--- a/server/routes/portalAdmin.js
+++ b/server/routes/portalAdmin.js
@@ -185,6 +185,28 @@ router.get('/customers', async (req, res) => {
}
})
+// --------------------------------------------- Einzelner Kunde (Stammdaten) --
+// Fuer die Rechnungsanalyse (kalkulation.html), die den Lead vorbefuellt.
+router.get('/customers/:customerId', async (req, res) => {
+ try {
+ const customer = await getDocument(config.collections.customers, req.params.customerId)
+ return res.json({
+ customer: {
+ id: customer.$id,
+ code: customer.code || '',
+ name: customer.name || '',
+ email: customer.email || '',
+ phone: customer.phone || '',
+ location: customer.location || '',
+ customerStatus: customer.customerStatus || '',
+ },
+ })
+ } catch (err) {
+ const status = err.status || 500
+ return res.status(status).json({ error: err.message || 'Kunde konnte nicht geladen werden' })
+ }
+})
+
// ------------------------------------------- Kundennummer/Stammdaten pflegen --
router.patch('/customers/:customerId', async (req, res) => {
const { code, location, phone } = req.body || {}
|