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 + + + +
+
+ webklar + ← Admin + + + Netto + 0 € + + + Stunden + 0,00 + + + Monatlich + + +
+
+ +
+ +
+
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.

+
+
+ + +
+ + + + + + + +
+
Termin
+

Gespräch festlegen

+

Nur wenn gewünscht: Mit wem, wann — und worum es geht. Der Termin hängt an der Zusammenfassung.

+ +
+ + +
+ + + +
+ + +
+
+ + +
+
+ QR-Code zum Kundenportal project.webklar.com +
+
+
Kundenportal
+

Code scannen und einloggen

+

Im Portal sieht der Kunde den Stand des Projekts, alle Dateien und die Rechnungen — + project.webklar.com

+
+ + +
+
+
+ + +
+ + + + + + + + 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)}

+ +
+

${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 || {}