Rechnungsanalyse: Kalkulationsseite fuer Leads im Admin-Panel
Bei Leads (customerStatus=lead) steht in der Kundenliste jetzt der Button "Rechnungsanalyse". Er oeffnet /kalkulation.html?customer=<id> - die Seite, die im Gespraech vor Ort oder am Telefon ausgefuellt wird. - Positionen nach Bereich (Website, Onlineshop, Softwareprojekt, Automatisierung/KI, laufende Kosten) mit Stundenwerten und Mengen - Preis ueber die Stundenstaffel (150/130/115/100), Brutto mit 19 % MwSt., Zahlungsmodalitaeten ab 1.000 / 3.000 EUR - Hinweis, ob selbst zusagbar (<= 5 Std), Termin abzustimmen oder ob Kenso freigeben muss (Shop, Software, Automatisierung) - Termin mit Justin/Kenso aus den hinterlegten Zeitfenstern, Notizen, Kundenportal-Zugang mit QR-Code - Zusammenfassung als Text zum Kopieren -> gehoert ins Akquise-Ticket Bewusst ohne eigenen Datensatz: dokumentiert wird im Ticket. Der Stand liegt pro Kunde nur im localStorage, damit ein angefangenes Gespraech nicht verloren geht. Fonts (Caprasimo, Figtree) liegen lokal unter public/fonts, kein Abruf beim Google-CDN. Neue Route GET /api/portal-admin/customers/:customerId liefert die Stammdaten zum Vorbefuellen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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 '<span class="pill pill-warn">Lead</span>'
|
||||
@@ -233,7 +237,10 @@ async function loadCustomers(search = '') {
|
||||
<td>${c.projectCount}</td>
|
||||
<td>
|
||||
<div class="cust-actions">
|
||||
<button class="mini-btn primary" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
|
||||
${isLead(c) ? `<a class="mini-btn primary" href="/kalkulation.html?customer=${encodeURIComponent(c.id)}"
|
||||
target="_blank" rel="noreferrer"
|
||||
title="Bedarf im Gespräch aufnehmen und Preis kalkulieren">Rechnungsanalyse</a>` : ''}
|
||||
<button class="mini-btn${isLead(c) ? '' : ' primary'}" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
|
||||
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
|
||||
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
|
||||
</div>
|
||||
|
||||
BIN
public/fonts/caprasimo-latin-ext.woff2
Normal file
BIN
public/fonts/caprasimo-latin-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/caprasimo-latin.woff2
Normal file
BIN
public/fonts/caprasimo-latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/figtree-latin-ext.woff2
Normal file
BIN
public/fonts/figtree-latin-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/figtree-latin.woff2
Normal file
BIN
public/fonts/figtree-latin.woff2
Normal file
Binary file not shown.
BIN
public/img/portal-qr.png
Normal file
BIN
public/img/portal-qr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
362
public/kalkulation.css
Normal file
362
public/kalkulation.css
Normal file
@@ -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); }
|
||||
}
|
||||
169
public/kalkulation.html
Normal file
169
public/kalkulation.html
Normal file
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Rechnungsanalyse – Webklar</title>
|
||||
<link rel="stylesheet" href="/kalkulation.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="kal-header">
|
||||
<div class="kal-header-inner">
|
||||
<span class="kal-brand">webklar</span>
|
||||
<a class="kal-back" href="/admin.html">← Admin</a>
|
||||
<span class="kal-spacer" id="kal-lead"></span>
|
||||
<span class="kal-stat">
|
||||
<span class="kal-stat-lbl">Netto</span>
|
||||
<span class="kal-stat-val big" id="hdr-net">0 €</span>
|
||||
</span>
|
||||
<span class="kal-stat">
|
||||
<span class="kal-stat-lbl">Stunden</span>
|
||||
<span class="kal-stat-val" id="hdr-hrs">0,00</span>
|
||||
</span>
|
||||
<span class="kal-stat">
|
||||
<span class="kal-stat-lbl">Monatlich</span>
|
||||
<span class="kal-stat-val" id="hdr-monthly">–</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Preis-Übersicht -->
|
||||
<section class="hero">
|
||||
<div class="kicker" id="hero-kicker">Ihr Projekt</div>
|
||||
<div class="hero-price-row">
|
||||
<div class="hero-price" id="hero-net">0 €</div>
|
||||
<div class="hero-price-sub" id="hero-sub">netto · 0 € brutto inkl. 19 % MwSt.</div>
|
||||
</div>
|
||||
<div class="hero-tiles">
|
||||
<div class="tile">
|
||||
<div class="tile-lbl">Aufwand</div>
|
||||
<div class="tile-val" id="hero-hrs">0,00 Std</div>
|
||||
<div class="tile-sub" id="hero-rate">Stundensatz 150 €/Std</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="tile-lbl">Monatlich</div>
|
||||
<div class="tile-val" id="hero-monthly">–</div>
|
||||
<div class="tile-sub" id="hero-monthly-sub">nichts Laufendes gewählt</div>
|
||||
</div>
|
||||
<div class="tile tile-pay">
|
||||
<div class="tile-lbl">Zahlung</div>
|
||||
<div class="tile-pay-title" id="pay-title">Noch nichts gewählt</div>
|
||||
<div class="tile-pay-text" id="pay-text">Kreuze an, was der Kunde braucht.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flag" id="flag"><b>Noch offen.</b> Sobald etwas gewählt ist, steht hier, ob du selbst abschließen kannst.</div>
|
||||
<div class="hero-actions">
|
||||
<button type="button" class="btn-solid" data-act="open-summary">Zusammenfassung</button>
|
||||
<button type="button" class="btn-outline" data-act="reset">Zurücksetzen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bereiche -->
|
||||
<section class="block">
|
||||
<h2>Worum geht es?</h2>
|
||||
<p class="block-lead">Bereiche antippen — nur die ausgewählten werden unten angezeigt.</p>
|
||||
<div class="chips" id="type-chips"></div>
|
||||
</section>
|
||||
|
||||
<!-- Positionen (gerendert) -->
|
||||
<div id="sections"></div>
|
||||
|
||||
<div class="empty-hint" id="empty-hint" hidden>
|
||||
<h3>Noch kein Bereich gewählt</h3>
|
||||
<p>Tippe oben an, worum es geht — dann erscheinen hier die Positionen.</p>
|
||||
</div>
|
||||
|
||||
<!-- Auswahl -->
|
||||
<section class="panel" id="chosen-panel" hidden>
|
||||
<div class="group-label" style="margin:0 0 14px" id="chosen-label">Ausgewählt (0)</div>
|
||||
<div class="chips" id="chosen-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Termin -->
|
||||
<section class="panel">
|
||||
<div class="kicker" style="color:var(--color-neutral-600)">Termin</div>
|
||||
<h2>Gespräch festlegen</h2>
|
||||
<p class="panel-lead">Nur wenn gewünscht: Mit wem, wann — und worum es geht. Der Termin hängt an der Zusammenfassung.</p>
|
||||
|
||||
<div class="seg" id="want-seg">
|
||||
<button type="button" data-act="want" data-want="0" class="on">Kein Termin nötig</button>
|
||||
<button type="button" data-act="want" data-want="1">Termin gewünscht</button>
|
||||
</div>
|
||||
|
||||
<div id="termin-body" hidden>
|
||||
<div class="chips" id="person-list"></div>
|
||||
|
||||
<div class="group-label" id="days-label">Freie Tage</div>
|
||||
<div class="day-grid" id="day-list"></div>
|
||||
|
||||
<div id="slot-wrap" hidden>
|
||||
<div class="group-label" id="slots-label">Uhrzeit</div>
|
||||
<div class="chips" id="slot-list"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px">
|
||||
<label class="field-label" for="term-note">Worum geht es im Gespräch?</label>
|
||||
<textarea class="kal-input" id="term-note"
|
||||
placeholder="z. B. Shop-Anbindung an die Warenwirtschaft klären"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="term-status" id="term-status">
|
||||
<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:18px">
|
||||
<label class="field-label" for="notes">Weitere Notizen zum Projekt</label>
|
||||
<textarea class="kal-input" id="notes" style="min-height:110px"
|
||||
placeholder="Alles, was sonst noch wichtig ist: Wünsche, Fristen, Ansprechpartner, Besonderheiten…"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Kundenportal -->
|
||||
<section class="portal-box">
|
||||
<div class="portal-qr">
|
||||
<img src="/img/portal-qr.png" alt="QR-Code zum Kundenportal project.webklar.com" width="180" height="180">
|
||||
</div>
|
||||
<div class="portal-info">
|
||||
<div class="kicker" style="color:var(--color-neutral-600)">Kundenportal</div>
|
||||
<h2>Code scannen und einloggen</h2>
|
||||
<p class="panel-lead">Im Portal sieht der Kunde den Stand des Projekts, alle Dateien und die Rechnungen —
|
||||
<a href="https://project.webklar.com/" target="_blank" rel="noreferrer">project.webklar.com</a></p>
|
||||
<div class="cred-row">
|
||||
<label class="cred">
|
||||
<span>E-Mail</span>
|
||||
<input id="portal-email" type="email" autocomplete="off" placeholder="kunde@firma.de">
|
||||
</label>
|
||||
<label class="cred">
|
||||
<span>Passwort</span>
|
||||
<input id="portal-pass" class="mono" type="text" autocomplete="off" placeholder="wird vergeben">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
webklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · 0170 4969375<br>
|
||||
Alle Preise netto zzgl. gesetzlicher Umsatzsteuer. Stundenwerte sind Richtwerte.
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<!-- Zusammenfassung -->
|
||||
<div class="backdrop" id="summary-dialog" hidden>
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="summary-title">
|
||||
<h2 id="summary-title">Zusammenfassung</h2>
|
||||
<textarea id="summary-text" readonly></textarea>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn-accent" data-act="copy">Kopieren</button>
|
||||
<button type="button" class="btn-quiet" data-act="close-summary">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/kalkulation.js"></script>
|
||||
<script>
|
||||
initKalkulation();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
707
public/kalkulation.js
Normal file
707
public/kalkulation.js
Normal file
@@ -0,0 +1,707 @@
|
||||
/* Rechnungsanalyse / Projektkalkulation.
|
||||
Wird aus dem Admin-Panel bei einem Lead geoeffnet (kalkulation.html?customer=<id>)
|
||||
und im Gespraech vor Ort oder am Telefon ausgefuellt.
|
||||
|
||||
Ergebnis ist bewusst KEIN eigener Datensatz: die Zusammenfassung wird kopiert
|
||||
und im Akquise-Ticket dokumentiert (dort wird gearbeitet). Damit ein
|
||||
angefangenes Gespraech nicht verloren geht, liegt der Stand pro Kunde im
|
||||
localStorage des Browsers. */
|
||||
|
||||
/* --------------------------------------------------------------- Katalog --- */
|
||||
const MWST = 19
|
||||
|
||||
const CATS = [
|
||||
{
|
||||
id: 'web', name: 'Website', kenso: false,
|
||||
hint: 'Alles, was zu einer Website gehört. Das Grundpaket deckt Startseite und drei Unterseiten ab.',
|
||||
groups: [
|
||||
{ g: 'Grundlage', items: [
|
||||
{ id: 'w1', n: 'Grundpaket Website', d: 'Startseite und bis zu 3 Unterseiten, Texte, Design, mobil, Impressum und Datenschutz', h: 2 },
|
||||
{ id: 'w2', n: 'Weitere Unterseite inkl. Text', d: 'Anzahl eintragen', h: 0.25, q: true },
|
||||
{ id: 'w3', n: 'Bilder beschaffen und aufbereiten', h: 0.5 },
|
||||
{ id: 'w4', n: 'Logo neu gestalten', d: 'einfaches Logo, kein Corporate Design', h: 0.5 },
|
||||
{ id: 'w5', n: 'Domain registrieren und einrichten', h: 0.25 },
|
||||
{ id: 'w6', n: 'Bestehende Domain umziehen', d: 'Relaunch: Kunde bringt Domain mit', h: 0.5 },
|
||||
{ id: 'w7', n: 'Inhalte von der alten Seite übernehmen', d: 'Relaunch', h: 1 },
|
||||
{ id: 'w8', n: 'E-Mail-Postfächer einrichten', h: 0.5 },
|
||||
] },
|
||||
{ g: 'Funktionen', items: [
|
||||
{ id: 'f1', n: 'Kontaktformular', h: 0.25 },
|
||||
{ id: 'f2', n: 'Anfahrt, Karte, Öffnungszeiten', h: 0.25 },
|
||||
{ id: 'f3', n: 'Bildergalerie oder Referenzen', h: 0.5 },
|
||||
{ id: 'f4', n: 'Google-Bewertungen einbinden', h: 0.5 },
|
||||
{ id: 'f5', n: 'Cookie-Banner und Consent-Tool', h: 0.5 },
|
||||
{ id: 'f6', n: 'Newsletter-Anmeldung', h: 0.5 },
|
||||
{ id: 'f7', n: 'Stellenanzeigen mit Bewerbungsformular', h: 0.75 },
|
||||
{ id: 'f8', n: 'Blog- oder Newsbereich', h: 1 },
|
||||
{ id: 'f9', n: 'Terminbuchung', h: 1.5 },
|
||||
{ id: 'f10', n: 'Weitere Sprache', d: 'Anzahl der zusätzlichen Sprachen', h: 1, q: true },
|
||||
] },
|
||||
{ g: 'Sichtbarkeit', items: [
|
||||
{ id: 's1', n: 'Google-Profil einrichten oder aufräumen', h: 0.75 },
|
||||
{ id: 's2', n: 'SEO-Grundoptimierung', h: 1 },
|
||||
{ id: 's3', n: 'Statistik und Tracking einrichten', h: 0.5 },
|
||||
{ id: 's4', n: 'Google Ads einrichten', d: 'Werbebudget zahlt der Kunde selbst', h: 1.5 },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'shop', name: 'Onlineshop', kenso: true,
|
||||
hint: 'Shop-Positionen stimmt Kenso mit ab — du nimmst hier nur den Bedarf auf.',
|
||||
groups: [
|
||||
{ g: 'Shop', items: [
|
||||
{ id: 'o1', n: 'Shop-Grundeinrichtung', d: 'Warenkorb, Kasse, Bestellabwicklung', h: 6 },
|
||||
{ id: 'o2', n: 'Produkte anlegen', d: 'Anzahl in 25er-Schritten', h: 1, q: true },
|
||||
{ id: 'o3', n: 'Zahlungsanbindung', h: 1.5 },
|
||||
{ id: 'o4', n: 'Versand- und Steuerregeln', h: 1 },
|
||||
{ id: 'o5', n: 'Gutscheine und Rabatte', h: 1 },
|
||||
{ id: 'o6', n: 'Kundenkonten im Shop', h: 1.5 },
|
||||
{ id: 'o7', n: 'Anbindung an die Warenwirtschaft', h: 4 },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'soft', name: 'Softwareprojekt', kenso: true,
|
||||
hint: 'Eigenentwicklung: Umfang aufnehmen, den Preis bestätigt Kenso.',
|
||||
groups: [
|
||||
{ g: 'Fundament', items: [
|
||||
{ id: 'p1', n: 'Projektfundament', d: 'Architektur, Datenmodell, Grundgerüst, Deployment', h: 8 },
|
||||
{ id: 'p2', n: 'Datenbank aufsetzen und modellieren', h: 4 },
|
||||
{ id: 'p3', n: 'Benutzerkonten und Login', h: 3 },
|
||||
{ id: 'p4', n: 'Rollen und Rechte', h: 2.5 },
|
||||
] },
|
||||
{ g: 'Oberfläche und Verwaltung', items: [
|
||||
{ id: 'a1', n: 'Admin-Panel Grundgerüst', h: 5 },
|
||||
{ id: 'a2', n: 'Weiterer Verwaltungsbereich', d: 'je Bereich zum Anlegen, Bearbeiten, Löschen', h: 1.5, q: true },
|
||||
{ id: 'a3', n: 'Produktkatalog mit Kategorien', h: 3 },
|
||||
{ id: 'a4', n: 'Such- und Filterfunktion', h: 2 },
|
||||
{ id: 'a5', n: 'Auswertungen und Dashboard', h: 3 },
|
||||
{ id: 'a6', n: 'Import und Export', d: 'CSV oder Excel', h: 1.5 },
|
||||
{ id: 'a7', n: 'Datei-Upload und Dokumentenablage', h: 1.5 },
|
||||
{ id: 'a8', n: 'Benachrichtigungen per E-Mail', h: 1.5 },
|
||||
] },
|
||||
{ g: 'App', items: [
|
||||
{ id: 'm1', n: 'Web-App', d: 'läuft im Browser, auch auf dem Handy', h: 6 },
|
||||
{ id: 'm2', n: 'Mobile App zusätzlich', d: 'iOS und Android, inkl. Store-Veröffentlichung', h: 12 },
|
||||
{ id: 'm3', n: 'Offline-Funktion', h: 4 },
|
||||
{ id: 'm4', n: 'Push-Benachrichtigungen', h: 2 },
|
||||
] },
|
||||
{ g: 'Schnittstellen', items: [
|
||||
{ id: 'i1', n: 'Schnittstelle zu einem Fremdsystem', d: 'Anzahl der Systeme', h: 4, q: true },
|
||||
{ id: 'i2', n: 'Zahlungsanbieter anbinden', h: 2 },
|
||||
{ id: 'i3', n: 'Datenübernahme aus einem Altsystem', h: 3 },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'auto', name: 'Automatisierung und KI', kenso: true,
|
||||
hint: 'Jeder Ablauf wird einzeln gezählt — lieber grob schätzen als weglassen.',
|
||||
groups: [
|
||||
{ g: 'Automatisierung', items: [
|
||||
{ id: 'z1', n: 'Wiederkehrenden Ablauf automatisieren', d: 'je Ablauf, z. B. Terminbestätigung oder Datenweitergabe', h: 3, q: true },
|
||||
{ id: 'z2', n: 'Automatische Angebots- oder Rechnungserzeugung', h: 3 },
|
||||
{ id: 'z3', n: 'Dokumente automatisch auslesen', d: 'Lieferscheine, Rechnungen, Formulare', h: 4 },
|
||||
{ id: 'z4', n: 'KI-Assistent mit eigenem Firmenwissen', d: 'laufende Nutzungskosten trägt der Kunde', h: 6 },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'run', name: 'Laufende Kosten', kenso: false, monthly: true,
|
||||
hint: 'Was nach dem Start jeden Monat läuft.',
|
||||
groups: [
|
||||
{ g: 'Monatlich', items: [
|
||||
{ id: 'r1', n: 'Wartung und Hosting', d: 'Betrieb, Backups, 1 Stunde Änderungen pro Monat', m: 50 },
|
||||
{ id: 'r2', n: 'Erhöhter Serverbedarf', d: 'eigene Datenbank oder App-Backend', m: 0, ask: true },
|
||||
{ id: 'r3', n: 'KI-Nutzung', d: 'ab 50 € je Anliegen pro Monat – je nach Umfang mehr', m: 50, q: true },
|
||||
] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const STAFFEL = [{ max: 5, r: 150 }, { max: 15, r: 130 }, { max: 40, r: 115 }, { max: 1e9, r: 100 }]
|
||||
|
||||
const PEOPLE = {
|
||||
justin: {
|
||||
name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J',
|
||||
slots: {
|
||||
1: ['09:00', '10:00', '11:00', '16:00', '17:00'],
|
||||
2: ['09:00', '10:00', '16:00', '17:00'],
|
||||
3: ['09:00', '10:00', '11:00', '16:00'],
|
||||
4: ['09:00', '10:00', '16:00', '17:00'],
|
||||
5: ['09:00', '10:00', '11:00'],
|
||||
},
|
||||
},
|
||||
kenso: {
|
||||
name: 'Kenso', role: 'Shop, Software, Automatisierung', initial: 'K',
|
||||
slots: { 2: ['14:00', '15:00', '18:00'], 4: ['14:00', '15:00', '18:00'], 6: ['10:00', '11:00'] },
|
||||
},
|
||||
}
|
||||
|
||||
const ITEM_BY_ID = {}
|
||||
for (const cat of CATS) {
|
||||
for (const grp of cat.groups) {
|
||||
for (const item of grp.items) ITEM_BY_ID[item.id] = { cat, item }
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- State --- */
|
||||
const state = {
|
||||
types: { web: true },
|
||||
sel: {},
|
||||
qty: {},
|
||||
wantTermin: false,
|
||||
person: 'justin',
|
||||
day: '',
|
||||
slot: '',
|
||||
termNote: '',
|
||||
notes: '',
|
||||
portalEmail: '',
|
||||
portalPass: '',
|
||||
}
|
||||
|
||||
let customer = null
|
||||
let storageKey = 'wk-kalkulation:neu'
|
||||
|
||||
/* --------------------------------------------------------------- Helfer --- */
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.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 = `<b>${escapeHtml(t.flagTitle)}</b> ${escapeHtml(t.flagText)}`
|
||||
}
|
||||
|
||||
function renderTypeChips(t) {
|
||||
el('type-chips').innerHTML = CATS.map((cat) => {
|
||||
const on = Boolean(state.types[cat.id])
|
||||
const count = t.sel.filter((x) => x.cat.id === cat.id).length
|
||||
return `<button type="button" class="chip${on ? ' on' : ''}" data-act="toggle-type" data-id="${cat.id}" aria-pressed="${on}">
|
||||
<span class="mark">${on ? '✓' : ''}</span>${escapeHtml(cat.name)}${count ? `<span class="count">${count}</span>` : ''}
|
||||
</button>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function itemHours(cat, item) {
|
||||
const q = item.q ? (state.qty[item.id] || 1) : 1
|
||||
if (cat.monthly) return item.ask ? 'n. Last' : `${eur((item.m || 0) * q)}/Mon`
|
||||
return `${fmt((item.h || 0) * q)} Std`
|
||||
}
|
||||
|
||||
function renderItem(cat, item) {
|
||||
const on = Boolean(state.sel[item.id])
|
||||
const q = state.qty[item.id] || 1
|
||||
return `<div class="item${on ? ' on' : ''}" role="button" tabindex="0" aria-pressed="${on}" data-act="toggle-item" data-id="${item.id}">
|
||||
<span class="mark">${on ? '✓' : ''}</span>
|
||||
<span class="item-body">
|
||||
<b class="item-name">${escapeHtml(item.n)}</b>
|
||||
${item.d ? `<span class="item-desc">${escapeHtml(item.d)}</span>` : ''}
|
||||
<span class="item-hrs">${escapeHtml(itemHours(cat, item))}</span>
|
||||
${item.q ? `<span class="qty">
|
||||
<button type="button" data-act="qty" data-id="${item.id}" data-delta="-1" aria-label="Anzahl verringern">−</button>
|
||||
<input type="number" min="1" step="1" value="${q}" data-act="qty-set" data-id="${item.id}" aria-label="Anzahl">
|
||||
<button type="button" data-act="qty" data-id="${item.id}" data-delta="1" aria-label="Anzahl erhöhen">+</button>
|
||||
</span>` : ''}
|
||||
</span>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderSections() {
|
||||
const visible = CATS.filter((cat) => state.types[cat.id])
|
||||
el('empty-hint').hidden = visible.length > 0
|
||||
el('sections').innerHTML = visible.map((cat) => `
|
||||
<section class="block">
|
||||
<div class="section-head">
|
||||
<h2>${escapeHtml(cat.name)}</h2>
|
||||
<span class="section-tag${cat.kenso ? ' kenso' : ''}">${cat.kenso ? 'Kenso gibt frei' : 'darfst du selbst'}</span>
|
||||
</div>
|
||||
<p class="block-lead">${escapeHtml(cat.hint)}</p>
|
||||
${cat.groups.map((grp) => `
|
||||
<div class="group-label">${escapeHtml(grp.g)}</div>
|
||||
<div class="item-grid">${grp.items.map((item) => renderItem(cat, item)).join('')}</div>
|
||||
`).join('')}
|
||||
</section>`).join('')
|
||||
}
|
||||
|
||||
function renderChosen(t) {
|
||||
el('chosen-panel').hidden = t.sel.length === 0
|
||||
el('chosen-label').textContent = `Ausgewählt (${t.sel.length})`
|
||||
el('chosen-list').innerHTML = t.sel.map((x) => {
|
||||
const value = x.cat.monthly
|
||||
? (x.item.ask ? 'n. Last' : `${eur(x.m)}/M`)
|
||||
: `${fmt(x.h)} Std`
|
||||
return `<span class="chosen-chip">${escapeHtml(x.item.n)}${x.q > 1 ? ` ×${x.q}` : ''}<b>${escapeHtml(value)}</b></span>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function renderTermin() {
|
||||
el('termin-body').hidden = !state.wantTermin
|
||||
document.querySelectorAll('#want-seg button').forEach((btn) => {
|
||||
btn.classList.toggle('on', (btn.dataset.want === '1') === state.wantTermin)
|
||||
})
|
||||
if (!state.wantTermin) return
|
||||
|
||||
el('person-list').innerHTML = Object.keys(PEOPLE).map((id) => {
|
||||
const p = PEOPLE[id]
|
||||
const on = state.person === id
|
||||
return `<button type="button" class="person${on ? ' on' : ''}" data-act="person" data-id="${id}" aria-pressed="${on}">
|
||||
<span class="ava">${p.initial}</span>
|
||||
<span><b>${escapeHtml(p.name)}</b><span class="role">${escapeHtml(p.role)}</span></span>
|
||||
</button>`
|
||||
}).join('')
|
||||
|
||||
const days = terminDays()
|
||||
el('days-label').textContent = `Freie Tage — ${PEOPLE[state.person].name}`
|
||||
el('day-list').innerHTML = days.map((d) => {
|
||||
const free = d.slots.length
|
||||
const on = state.day === d.key && free > 0
|
||||
return `<button type="button" class="day${on ? ' on' : ''}" data-act="day" data-key="${d.key}"${free ? '' : ' disabled'}>
|
||||
<span class="wd">${d.date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}</span>
|
||||
<span class="dm">${d.date.getDate()}.${d.date.getMonth() + 1}.</span>
|
||||
<span class="free">${free ? `${free} frei` : '—'}</span>
|
||||
</button>`
|
||||
}).join('')
|
||||
|
||||
const day = selectedDay()
|
||||
el('slot-wrap').hidden = !day
|
||||
if (day) {
|
||||
el('slots-label').textContent = `Uhrzeit am ${longDay(day.date)}`
|
||||
el('slot-list').innerHTML = day.slots.map((s) => {
|
||||
const on = state.slot === s
|
||||
return `<button type="button" class="slot${on ? ' on' : ''}" data-act="slot" data-t="${s}" aria-pressed="${on}">${s}</button>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
renderTermStatus()
|
||||
}
|
||||
|
||||
function renderTermStatus() {
|
||||
const box = el('term-status')
|
||||
const line = terminLine()
|
||||
if (line) {
|
||||
box.className = 'term-status fixed'
|
||||
box.innerHTML = `<b>Termin steht:</b> ${escapeHtml(line)}${state.termNote ? ` — Thema: ${escapeHtml(state.termNote)}` : ''}`
|
||||
} else {
|
||||
box.className = 'term-status'
|
||||
box.innerHTML = '<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.'
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const t = totals()
|
||||
renderNumbers(t)
|
||||
renderTypeChips(t)
|
||||
renderSections()
|
||||
renderChosen(t)
|
||||
renderTermin()
|
||||
saveState()
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Aktionen --- */
|
||||
function toggleItem(id) {
|
||||
if (!ITEM_BY_ID[id]) return
|
||||
state.sel[id] = !state.sel[id]
|
||||
render()
|
||||
}
|
||||
|
||||
function changeQty(id, delta) {
|
||||
state.qty[id] = Math.max(1, (state.qty[id] || 1) + delta)
|
||||
if (!state.sel[id]) state.sel[id] = true
|
||||
render()
|
||||
}
|
||||
|
||||
function setQty(id, value) {
|
||||
const n = Math.max(1, parseInt(value, 10) || 1)
|
||||
state.qty[id] = n
|
||||
render()
|
||||
}
|
||||
|
||||
function openSummary() {
|
||||
el('summary-text').value = summaryText()
|
||||
el('summary-dialog').hidden = false
|
||||
}
|
||||
|
||||
function closeSummary() {
|
||||
el('summary-dialog').hidden = true
|
||||
}
|
||||
|
||||
async function copySummary(btn) {
|
||||
const text = el('summary-text').value
|
||||
let ok = false
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ok = true
|
||||
} catch {
|
||||
// Fallback fuer Browser ohne Clipboard-API bzw. ohne HTTPS-Kontext
|
||||
const area = el('summary-text')
|
||||
area.removeAttribute('readonly')
|
||||
area.select()
|
||||
ok = document.execCommand('copy')
|
||||
area.setAttribute('readonly', '')
|
||||
}
|
||||
btn.textContent = ok ? 'Kopiert ✓' : 'Bitte manuell markieren'
|
||||
setTimeout(() => { btn.textContent = 'Kopieren' }, 2200)
|
||||
}
|
||||
|
||||
function resetSelection() {
|
||||
if (!confirm('Alle angekreuzten Positionen zurücksetzen? Notizen und Termin bleiben erhalten.')) return
|
||||
state.sel = {}
|
||||
state.qty = {}
|
||||
render()
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- Init --- */
|
||||
function bindEvents() {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target.closest('[data-act]')
|
||||
if (!target) return
|
||||
const act = target.dataset.act
|
||||
|
||||
// Klicks auf die Anzahl-Steuerung treffen zuerst deren eigenes data-act,
|
||||
// schalten die Karte also nicht mit um.
|
||||
switch (act) {
|
||||
case 'toggle-type':
|
||||
state.types[target.dataset.id] = !state.types[target.dataset.id]
|
||||
render()
|
||||
break
|
||||
case 'toggle-item':
|
||||
toggleItem(target.dataset.id)
|
||||
break
|
||||
case 'qty':
|
||||
changeQty(target.dataset.id, Number(target.dataset.delta))
|
||||
break
|
||||
case 'want':
|
||||
state.wantTermin = target.dataset.want === '1'
|
||||
render()
|
||||
break
|
||||
case 'person':
|
||||
state.person = target.dataset.id
|
||||
state.day = ''
|
||||
state.slot = ''
|
||||
render()
|
||||
break
|
||||
case 'day':
|
||||
state.day = target.dataset.key
|
||||
state.slot = ''
|
||||
render()
|
||||
break
|
||||
case 'slot':
|
||||
state.slot = target.dataset.t
|
||||
render()
|
||||
break
|
||||
case 'open-summary': openSummary(); break
|
||||
case 'close-summary': closeSummary(); break
|
||||
case 'copy': copySummary(target); break
|
||||
case 'reset': resetSelection(); break
|
||||
default: break
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('change', (event) => {
|
||||
const input = event.target.closest('[data-act="qty-set"]')
|
||||
if (!input) return
|
||||
setQty(input.dataset.id, input.value)
|
||||
})
|
||||
|
||||
// Karten sind divs mit role=button -> Tastatur nachruesten.
|
||||
// Im Anzahl-Feld und auf den +/- Buttons darf das nicht greifen.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
if (event.target.closest('input, textarea, button')) return
|
||||
const card = event.target.closest('[data-act="toggle-item"]')
|
||||
if (!card) return
|
||||
event.preventDefault()
|
||||
toggleItem(card.dataset.id)
|
||||
})
|
||||
|
||||
el('term-note').addEventListener('input', (event) => {
|
||||
state.termNote = event.target.value
|
||||
renderTermStatus()
|
||||
saveState()
|
||||
})
|
||||
el('notes').addEventListener('input', (event) => {
|
||||
state.notes = event.target.value
|
||||
saveState()
|
||||
})
|
||||
el('portal-email').addEventListener('input', (event) => {
|
||||
state.portalEmail = event.target.value
|
||||
saveState()
|
||||
})
|
||||
el('portal-pass').addEventListener('input', (event) => {
|
||||
state.portalPass = event.target.value
|
||||
saveState()
|
||||
})
|
||||
|
||||
el('summary-dialog').addEventListener('click', (event) => {
|
||||
if (event.target === el('summary-dialog')) closeSummary()
|
||||
})
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !el('summary-dialog').hidden) closeSummary()
|
||||
})
|
||||
}
|
||||
|
||||
async function initKalkulation() {
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
if (!me.authenticated || me.role !== 'admin') {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = new URLSearchParams(window.location.search).get('customer') || ''
|
||||
if (customerId) {
|
||||
storageKey = `wk-kalkulation:${customerId}`
|
||||
try {
|
||||
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`)
|
||||
customer = data.customer
|
||||
} catch (err) {
|
||||
console.warn('Kunde konnte nicht geladen werden:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
restoreState()
|
||||
|
||||
if (customer) {
|
||||
const parts = [customer.name, customer.location].filter(Boolean)
|
||||
el('kal-lead').innerHTML =
|
||||
`<span style="font-size:13.5px;color:var(--color-accent-2-100)">Lead: <b style="color:#fff">${escapeHtml(parts.join(' · '))}</b></span>`
|
||||
el('hero-kicker').textContent = `Angebot für ${customer.name}`
|
||||
if (!state.portalEmail) state.portalEmail = customer.email || ''
|
||||
}
|
||||
|
||||
el('term-note').value = state.termNote
|
||||
el('notes').value = state.notes
|
||||
el('portal-email').value = state.portalEmail
|
||||
el('portal-pass').value = state.portalPass
|
||||
|
||||
bindEvents()
|
||||
render()
|
||||
}
|
||||
|
||||
window.initKalkulation = initKalkulation
|
||||
Reference in New Issue
Block a user