Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a4fa581c0
commit
7c00633db0
43 files changed
+229
-4001
No files matched your search
@@ -1,309 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin – Webklar Kundenbereich</title>
|
||||
<link rel="stylesheet" href="/login.css">
|
||||
<style>
|
||||
.admin-tabs { display: flex; gap: 8px; margin: 18px 0; flex-wrap: wrap; }
|
||||
.admin-tab {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
|
||||
color: inherit; padding: 8px 16px; border-radius: 999px; cursor: pointer; font: inherit;
|
||||
}
|
||||
.admin-tab.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
|
||||
.admin-section { display: none; }
|
||||
.admin-section.active { display: block; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.stat-tile { padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.stat-tile .num { font-size: 26px; font-weight: 700; display: block; }
|
||||
.stat-tile .lbl { font-size: 12px; opacity: 0.7; }
|
||||
.admin-search { width: 100%; max-width: 380px; padding: 9px 14px; border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; margin-bottom: 12px; }
|
||||
.admin-table-wrap { overflow-x: auto; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); }
|
||||
/* Kundentabelle: volle Breite, kein Links-/Rechts-Scroll */
|
||||
.admin-table-wrap.no-scroll { overflow-x: visible; }
|
||||
.admin-table-wrap.no-scroll table.admin-table { table-layout: fixed; }
|
||||
.admin-table-wrap.no-scroll .admin-table td { word-break: break-word; overflow-wrap: anywhere; }
|
||||
.cust-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
table.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.admin-table th, .admin-table td { text-align: left; padding: 9px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); vertical-align: middle; }
|
||||
.admin-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; }
|
||||
.pill-ok { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.4); }
|
||||
.pill-warn { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
|
||||
.pill-muted { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); opacity: 0.8; }
|
||||
.pill-red { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.45); }
|
||||
.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; }
|
||||
.detail-row td { background: rgba(255,255,255,0.03); }
|
||||
.detail-box { padding: 6px 4px; }
|
||||
.detail-box h4 { margin: 8px 0 4px; font-size: 13px; }
|
||||
.detail-box .muted { opacity: 0.65; }
|
||||
.ws-line { font-size: 13px; opacity: 0.85; margin: 2px 0 2px 14px; }
|
||||
a.plain { color: #7dd3fc; text-decoration: none; }
|
||||
a.plain:hover { text-decoration: underline; }
|
||||
.admin-note { font-size: 13px; opacity: 0.65; margin: 8px 0 0; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.recent-list { list-style: none; margin: 0; padding: 0; }
|
||||
.recent-list li { padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.07); font-size: 14px; }
|
||||
.recent-list li:last-child { border-bottom: none; }
|
||||
.two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
|
||||
/* Chat */
|
||||
.badge-pill {
|
||||
display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
|
||||
align-items: center; justify-content: center; border-radius: 999px;
|
||||
background: #f87171; color: #fff; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 14px; min-height: 480px; }
|
||||
@media (max-width: 760px) { .chat-layout { grid-template-columns: 1fr; } }
|
||||
.chat-side { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.chat-list { border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow-y: auto; max-height: 560px; }
|
||||
.chat-list-item { padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.07); cursor: pointer; }
|
||||
.chat-list-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.chat-list-item.active { background: rgba(94,234,212,0.1); }
|
||||
.chat-list-item .last { font-size: 12px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-thread { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding: 4px; max-height: 440px; }
|
||||
.chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
|
||||
.chat-bubble .chat-meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; }
|
||||
.chat-bubble.mine { align-self: flex-end; background: rgba(94,234,212,0.18); border: 1px solid rgba(94,234,212,0.45); border-bottom-right-radius: 4px; }
|
||||
.chat-bubble.theirs { align-self: flex-start; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); border-bottom-left-radius: 4px; }
|
||||
.chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
|
||||
.chat-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 44px; max-height: 120px; padding: 10px 12px;
|
||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
|
||||
}
|
||||
.chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; }
|
||||
/* Anhaenge und Sprachnachrichten (chat-composer.css) im Tuerkis des Admin-Panels */
|
||||
body {
|
||||
--chat-accent: #2dd4bf; --chat-accent-hover: #5eead4; --chat-accent-soft: rgba(94,234,212,0.16);
|
||||
--chat-accent-line: rgba(94,234,212,0.5); --chat-accent-text: #5eead4; --chat-on-accent: #042f2e;
|
||||
}
|
||||
|
||||
/* Übernahmegutschriften */
|
||||
.credit-filter { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.credit-filter button {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
|
||||
color: inherit; padding: 5px 13px; border-radius: 999px; cursor: pointer; font: inherit; font-size: 13px;
|
||||
}
|
||||
.credit-filter button.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
|
||||
tr.credit-active td { background: rgba(94,234,212,0.07); }
|
||||
.credit-detail { margin-top: 18px; }
|
||||
.credit-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.credit-head h3 { margin: 0; }
|
||||
.credit-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 22px; }
|
||||
.credit-cols h4 { margin: 16px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
|
||||
.credit-cols h4:first-child { margin-top: 4px; }
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 14px; margin: 0; }
|
||||
.kv dt { opacity: 0.6; }
|
||||
.kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.pre { white-space: pre-wrap; font-size: 14px; margin: 0; overflow-wrap: anywhere; }
|
||||
.check-list { list-style: none; margin: 0; padding: 0; font-size: 14px; }
|
||||
.check-list li { padding: 5px 0 5px 26px; position: relative; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.check-list li:last-child { border-bottom: none; }
|
||||
.check-list li::before { position: absolute; left: 2px; top: 5px; font-weight: 700; }
|
||||
.check-list li.ok::before { content: '✓'; color: #4ade80; }
|
||||
.check-list li.warn::before { content: '!'; color: #fbbf24; left: 6px; }
|
||||
.check-list li.info::before { content: 'i'; color: #7dd3fc; left: 6px; font-style: italic; }
|
||||
.check-list small { display: block; opacity: 0.6; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.manual-checks label { display: flex; gap: 9px; align-items: flex-start; font-size: 14px; padding: 4px 0; cursor: pointer; }
|
||||
.manual-checks input { margin-top: 3px; width: 16px; height: 16px; accent-color: #5eead4; flex: none; }
|
||||
.calc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
|
||||
.calc-grid label, .credit-field label { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 4px; }
|
||||
.credit-input { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 9px; border: 1px solid rgba(255,255,255,0.2);
|
||||
background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 14px; }
|
||||
.credit-input option { color: #111; }
|
||||
textarea.credit-input { min-height: 80px; resize: vertical; }
|
||||
.calc-result { margin: 10px 0; padding: 9px 12px; border-radius: 10px; font-size: 14px; background: rgba(94,234,212,0.08); border: 1px solid rgba(94,234,212,0.3); }
|
||||
/* E-Mail-Speicher: ab der Warnschwelle des Hetzner-Pakets rot */
|
||||
.calc-result.warn { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.45); color: #fca5a5; }
|
||||
.storage-task strong { font-size: 15px; }
|
||||
.storage-partial { color: #fca5a5; }
|
||||
.storage-failed { margin: 14px 0 0; }
|
||||
.storage-failed li { margin: 4px 0; font-size: 14px; }
|
||||
.credit-field { margin-top: 10px; }
|
||||
.credit-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.mini-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.mini-btn.danger { border-color: rgba(248,113,113,0.5); }
|
||||
.credit-msg { margin-top: 10px; font-size: 14px; }
|
||||
.history-item { font-size: 13px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/chat-composer.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
<div class="dot-pattern"></div>
|
||||
<div class="dot-fade"></div>
|
||||
</div>
|
||||
|
||||
<header class="portal-header">
|
||||
<a class="portal-logo" href="/admin.html">Webklar <span style="opacity:.6">Admin</span></a>
|
||||
<div class="portal-header-actions">
|
||||
<span class="portal-meta" id="admin-meta"></span>
|
||||
<a class="portal-btn-outline" href="https://ticket.webklar.com/tickets">← Ticketsystem</a>
|
||||
<button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="portal-main dashboard-content-reveal">
|
||||
<h1 class="portal-title">Admin-Übersicht</h1>
|
||||
<p id="admin-error" class="error hidden"></p>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-tab="overview">Übersicht</button>
|
||||
<button class="admin-tab" data-tab="projects">Projekte</button>
|
||||
<button class="admin-tab" data-tab="customers">Kunden</button>
|
||||
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="offers">Abo-Angebote</button>
|
||||
<button class="admin-tab" data-tab="storage">E-Mail-Speicher</button>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht -->
|
||||
<section class="admin-section active" id="tab-overview">
|
||||
<div class="stat-grid" id="stat-grid"><span class="portal-status">Lade…</span></div>
|
||||
<div class="two-col">
|
||||
<div class="portal-card">
|
||||
<h3>Letzte Portal-Logins</h3>
|
||||
<ul class="recent-list" id="recent-logins"><li class="muted">Lade…</li></ul>
|
||||
</div>
|
||||
<div class="portal-card">
|
||||
<h3>Zuletzt aktualisierte Projekte</h3>
|
||||
<ul class="recent-list" id="recent-projects"><li class="muted">Lade…</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Projekte -->
|
||||
<section class="admin-section" id="tab-projects">
|
||||
<input class="admin-search" id="project-search" type="search"
|
||||
placeholder="Projekte suchen (Name, Subdomain, Repo, Kunde)…">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Projekt</th><th>Subdomain</th><th>Kunde</th><th>Status</th><th>Typ</th><th>Links</th></tr>
|
||||
</thead>
|
||||
<tbody id="project-rows"><tr><td colspan="6" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">Als Admin kannst du jede Projekt-Vorschau direkt öffnen – ohne Kunden-Login.</p>
|
||||
</section>
|
||||
|
||||
<!-- Kunden -->
|
||||
<section class="admin-section" id="tab-customers">
|
||||
<input class="admin-search" id="customer-search" type="search"
|
||||
placeholder="Kunden suchen (Kundennr., Name, E-Mail, Ort)…">
|
||||
<div class="admin-table-wrap no-scroll">
|
||||
<table class="admin-table">
|
||||
<colgroup>
|
||||
<col style="width:9%"><col style="width:23%"><col style="width:11%"><col style="width:11%">
|
||||
<col style="width:13%"><col style="width:8%"><col style="width:25%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Kundennr.</th><th>Kunde</th><th>Status</th><th>Portal</th><th>Letzter Login</th><th>Projekte</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody id="customer-rows"><tr><td colspan="7" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.</p>
|
||||
</section>
|
||||
|
||||
<!-- Chat -->
|
||||
<section class="admin-section" id="tab-chat">
|
||||
<div class="chat-layout">
|
||||
<div class="chat-side">
|
||||
<input class="credit-input" id="admin-chat-new" list="admin-chat-new-list" autocomplete="off"
|
||||
placeholder="Neue Nachricht an Kunde…" aria-label="Neue Nachricht an Kunde">
|
||||
<datalist id="admin-chat-new-list"></datalist>
|
||||
<div class="chat-list" id="admin-chat-list">
|
||||
<div class="chat-list-item"><span class="muted">Lade Unterhaltungen…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-thread" id="admin-chat-thread">
|
||||
<div class="chat-empty">Unterhaltung links auswählen</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Übernahmegutschriften -->
|
||||
<section class="admin-section" id="tab-credits">
|
||||
<div class="credit-filter">
|
||||
<button type="button" class="active" data-credit-filter="open">Offen</button>
|
||||
<button type="button" data-credit-filter="all">Alle</button>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Eingang</th><th>Kunde</th><th>Leistung / bisher bei</th><th>Rechnung</th><th>Bezahlt bis</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="credit-rows"><tr><td colspan="7" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="credit-detail" class="portal-card credit-detail hidden"></div>
|
||||
<p class="admin-note">Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.</p>
|
||||
</section>
|
||||
|
||||
<!-- Abo-Angebote -->
|
||||
<section class="admin-section" id="tab-offers">
|
||||
<div class="flex-between" style="margin-bottom:12px">
|
||||
<div class="credit-filter" style="margin:0">
|
||||
<button type="button" class="active" data-offer-filter="open">Offen & aktiv</button>
|
||||
<button type="button" data-offer-filter="all">Alle</button>
|
||||
</div>
|
||||
<button type="button" class="mini-btn primary" id="offer-new">Neues Angebot</button>
|
||||
</div>
|
||||
<div id="offer-message" class="calc-result hidden"></div>
|
||||
<div id="offer-form" class="portal-card hidden" style="margin-bottom:16px"></div>
|
||||
<div class="admin-table-wrap no-scroll">
|
||||
<table class="admin-table">
|
||||
<colgroup>
|
||||
<col style="width:15%"><col style="width:19%"><col style="width:33%"><col style="width:15%"><col style="width:18%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Angelegt</th><th>Kunde</th><th>Angebot</th><th>Gültig bis</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody id="offer-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
|
||||
</section>
|
||||
|
||||
<!-- E-Mail-Speicher (Zusatzspeicher-Aufgaben fuers Team, Hetzner-Paket) -->
|
||||
<section class="admin-section" id="tab-storage">
|
||||
<div id="storage-summary" class="calc-result">Lade…</div>
|
||||
<div class="admin-table-wrap no-scroll">
|
||||
<table class="admin-table">
|
||||
<colgroup>
|
||||
<col style="width:14%"><col style="width:22%"><col style="width:34%"><col style="width:18%"><col style="width:12%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Fällig</th><th>Kunde</th><th>Aufgabe in konsoleH</th><th>Bestellt</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="storage-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="storage-failed" class="storage-failed hidden">
|
||||
<h4>Gescheiterte Änderungen (letzte 30 Tage)</h4>
|
||||
<ul class="recent-list" id="storage-failed-list"></ul>
|
||||
</div>
|
||||
<p class="admin-note">Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/chat-composer.js"></script>
|
||||
<script src="/admin.js"></script>
|
||||
<script>
|
||||
initAdminPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
-1418
File diff suppressed because it is too large.
Load diff
+7
-19
@@ -570,28 +570,16 @@ async function initLoginPage() {
|
||||
/* --------------------------------------------------------------- View-as --- */
|
||||
function renderViewAsBanner(me) {
|
||||
const banner = document.getElementById('viewas-banner')
|
||||
if (!banner || !me.viewAs) return
|
||||
if (!banner || !me.viewAs || !me.viewId) return
|
||||
banner.classList.remove('hidden')
|
||||
// Kundenansicht aus dem Ticketsystem (mit Kennung): Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
|
||||
if (me.viewId) {
|
||||
banner.innerHTML = `
|
||||
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
|
||||
<button type="button" id="exit-viewas-btn">Beenden</button>
|
||||
`
|
||||
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
|
||||
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
|
||||
window.location.href = redirect || 'https://ticket.webklar.com/customers'
|
||||
})
|
||||
return
|
||||
}
|
||||
// Alte Ansicht "Als Kunde ansehen" aus dem Admin-Panel (bis das Panel abgeschaltet ist)
|
||||
// Kundenansicht aus dem Ticketsystem: Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
|
||||
banner.innerHTML = `
|
||||
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
|
||||
<button type="button" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
|
||||
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
|
||||
<button type="button" id="exit-viewas-btn">Beenden</button>
|
||||
`
|
||||
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
|
||||
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
|
||||
window.location.href = '/admin.html'
|
||||
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
|
||||
window.location.href = redirect || 'https://ticket.webklar.com/customers'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1973,7 +1961,7 @@ function showView(name, { updateHash = true } = {}) {
|
||||
}
|
||||
|
||||
/* Profilbild (DiceBear Big Smile vom eigenen Server); die Initialen bleiben stehen, bis das Bild geladen ist,
|
||||
und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass "Als Kunde ansehen" nicht das gecachte Bild zeigt. */
|
||||
und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass die Kundenansicht nicht das gecachte Bild zeigt. */
|
||||
function setAvatar(el, initials, seed) {
|
||||
if (!el) return
|
||||
el.textContent = initials
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Admin-Panel, siehe chat-composer.js).
|
||||
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, admin.html setzt Tuerkis. */
|
||||
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Ticketsystem, siehe chat-composer.js).
|
||||
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, das Ticketsystem setzt eigene (styles/chat.css). */
|
||||
:root {
|
||||
--chat-accent: #9d3dfe;
|
||||
--chat-accent-hover: #8a25f0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Chat im Kundenbereich und im Admin-Panel: Anhaenge (Bild, Kamera, Datei) und Sprachnachrichten.
|
||||
/* Chat im Kundenbereich und im Ticketsystem (laedt diese Datei zur Laufzeit): Anhaenge (Bild, Kamera, Datei) und Sprachnachrichten.
|
||||
ChatComposer.create({...}) baut die Eingabezeile, ChatComposer.renderMessage(...) eine Sprechblase.
|
||||
Erlaubte Endungen und Grenzen prueft der Server noch einmal (server/services/chatAttachments.js). */
|
||||
(function () {
|
||||
@@ -468,7 +468,7 @@
|
||||
|
||||
/**
|
||||
* Sprechblase: Anhaenge, Text, Absender/Zeit.
|
||||
* fileUrl(att) liefert die Adresse des Anhangs (Kunde: /api/chat/..., Admin: /api/portal-admin/...).
|
||||
* fileUrl(att) liefert die Adresse des Anhangs (Kunde: /api/chat/..., Ticketsystem: signierter Link unter /api/team/...).
|
||||
*/
|
||||
function renderMessage(msg, { mine, meta, fileUrl }) {
|
||||
const atts = Array.isArray(msg.attachments) ? msg.attachments : []
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
/* Logo ist zugleich der Zurueck-Link ins Admin-Panel. */
|
||||
.kal-brand {
|
||||
font-family: var(--font-heading); font-size: clamp(17px, 4vw, 22px);
|
||||
color: #fff; text-decoration: none;
|
||||
}
|
||||
.kal-brand:hover { color: #fff; opacity: 0.85; }
|
||||
.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); }
|
||||
.term-save { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 14px; }
|
||||
.term-save button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.term-save-hint { font-size: 13px; color: var(--color-neutral-600); flex: 1; min-width: 200px; text-wrap: pretty; }
|
||||
.term-save-hint.done { color: var(--color-accent-2-700); font-weight: 700; }
|
||||
.term-save-hint.error { color: var(--color-accent-800); font-weight: 700; }
|
||||
.slot:disabled { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
|
||||
.slot.taken-own {
|
||||
border-color: var(--color-accent-2-500); background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-800); text-decoration: none; opacity: 1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 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); }
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<!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">
|
||||
<!-- Der Weg zurueck ins Admin-Panel steckt im Logo. -->
|
||||
<a class="kal-brand" href="/admin.html" title="Zurück zum Admin-Panel">webklar</a>
|
||||
<span class="kal-spacer"></span>
|
||||
<span class="kal-stat">
|
||||
<span class="kal-stat-lbl">Projektpreis</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">Endpreis · gemäß § 19 UStG ohne Umsatzsteuer</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 class="term-save">
|
||||
<button type="button" class="btn-accent" id="term-save" data-act="save-termin" disabled>
|
||||
Termin ins Ticket eintragen
|
||||
</button>
|
||||
<span class="term-save-hint" id="term-save-hint">Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.</span>
|
||||
</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 sind Endpreise. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). 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>
|
||||
@@ -1,838 +0,0 @@
|
||||
/* 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 --- */
|
||||
// Kleinunternehmer (§ 19 UStG): alle Preise sind Endpreise, keine Umsatzsteuer
|
||||
const USt_HINWEIS = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung).'
|
||||
|
||||
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 },
|
||||
] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Stundensatz: 100 € für kleine Aufträge, ab 10 Stunden (größeres Projekt) 80 €
|
||||
const STAFFEL = [{ max: 9.99, r: 100 }, { max: 1e9, r: 80 }]
|
||||
|
||||
const PEOPLE = {
|
||||
justin: {
|
||||
name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J',
|
||||
email: 'justin@webklar.com',
|
||||
slots: {
|
||||
1: ['09:00', '10:00', '11:00', '16:00', '17:00'],
|
||||
2: ['09:00', '10:00', '16:00', '17:00'],
|
||||
3: ['09:00', '10:00', '11:00', '16:00'],
|
||||
4: ['09:00', '10:00', '16:00', '17:00'],
|
||||
5: ['09:00', '10:00', '11:00'],
|
||||
},
|
||||
},
|
||||
kenso: {
|
||||
name: 'Kenso', role: 'Shop, Software, Automatisierung', initial: 'K',
|
||||
email: 'kenso@webklar.com',
|
||||
slots: { 2: ['14:00', '15:00', '18:00'], 4: ['14:00', '15:00', '18:00'], 6: ['10:00', '11:00'] },
|
||||
},
|
||||
}
|
||||
|
||||
const ITEM_BY_ID = {}
|
||||
for (const cat of CATS) {
|
||||
for (const grp of cat.groups) {
|
||||
for (const item of grp.items) ITEM_BY_ID[item.id] = { cat, item }
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- State --- */
|
||||
const state = {
|
||||
types: { web: true },
|
||||
sel: {},
|
||||
qty: {},
|
||||
wantTermin: false,
|
||||
person: 'justin',
|
||||
day: '',
|
||||
slot: '',
|
||||
termNote: '',
|
||||
notes: '',
|
||||
portalEmail: '',
|
||||
portalPass: '',
|
||||
}
|
||||
|
||||
let customer = null
|
||||
let storageKey = 'wk-kalkulation:neu'
|
||||
|
||||
/* Schon vergebene Termine (aus den Akquise-Tickets), damit nichts doppelt
|
||||
gebucht wird. Wird beim Laden und nach jedem Speichern aktualisiert. */
|
||||
let booked = []
|
||||
let savedAppointment = null
|
||||
|
||||
/* --------------------------------------------------------------- Helfer --- */
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function eur(n) {
|
||||
return Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' €'
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(data.error || `Fehler ${response.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Zwischenspeicher --- */
|
||||
function saveState() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ ...state, savedAt: new Date().toISOString() }))
|
||||
} catch {
|
||||
/* z. B. privater Modus: dann eben ohne Zwischenspeicher */
|
||||
}
|
||||
}
|
||||
|
||||
function restoreState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return
|
||||
const saved = JSON.parse(raw)
|
||||
for (const key of Object.keys(state)) {
|
||||
if (saved[key] !== undefined && saved[key] !== null) state[key] = saved[key]
|
||||
}
|
||||
} catch {
|
||||
/* kaputter Eintrag: ignorieren */
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Rechnen --- */
|
||||
function picked() {
|
||||
const out = []
|
||||
for (const cat of CATS) {
|
||||
for (const grp of cat.groups) {
|
||||
for (const item of grp.items) {
|
||||
if (!state.sel[item.id]) continue
|
||||
const q = item.q ? (state.qty[item.id] || 1) : 1
|
||||
out.push({
|
||||
cat, item, q,
|
||||
h: cat.monthly ? 0 : (item.h || 0) * q,
|
||||
m: cat.monthly ? (item.m || 0) * q : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function totals() {
|
||||
const sel = picked()
|
||||
const hrsNum = sel.reduce((a, x) => a + x.h, 0)
|
||||
const rateNum = STAFFEL.find((s) => hrsNum <= s.max).r
|
||||
const netNum = hrsNum * rateNum
|
||||
const monthlyItems = sel.filter((x) => x.cat.monthly)
|
||||
const askServer = monthlyItems.some((x) => x.item.ask)
|
||||
const mSum = monthlyItems.reduce((a, x) => a + x.m, 0)
|
||||
const kenso = sel.some((x) => x.cat.kenso)
|
||||
|
||||
let payTitle = 'Noch nichts gewählt'
|
||||
let payText = 'Kreuze an, was der Kunde braucht.'
|
||||
if (netNum >= 3000) {
|
||||
payTitle = '40 / 40 / 20'
|
||||
payText = `${eur(netNum * 0.4)} bei Auftrag, ${eur(netNum * 0.4)} bei Designfreigabe, ${eur(netNum * 0.2)} nach Fertigstellung.`
|
||||
} else if (netNum >= 1000) {
|
||||
payTitle = '50 % Anzahlung'
|
||||
payText = `${eur(netNum * 0.5)} bei Auftragserteilung, der Rest nach der Abnahme.`
|
||||
} else if (netNum > 0) {
|
||||
payTitle = 'Keine Anzahlung'
|
||||
payText = 'Eine Rechnung nach der Abnahme, zahlbar in 14 Tagen.'
|
||||
}
|
||||
|
||||
let flagTitle = 'Noch offen.'
|
||||
let flagText = 'Sobald etwas gewählt ist, steht hier, ob du selbst abschließen kannst.'
|
||||
let tone = 'neutral'
|
||||
if (netNum > 0 || monthlyItems.length) {
|
||||
if (kenso) {
|
||||
tone = 'kenso'
|
||||
flagTitle = 'Kenso gibt frei.'
|
||||
flagText = 'Ein paar Positionen laufen über ihn. Preis und Termin nennst du erst nach seinem Okay — die Zahl hier ist deine Orientierung.'
|
||||
} else if (hrsNum <= 5) {
|
||||
tone = 'ok'
|
||||
flagTitle = 'Kannst du selbst zusagen.'
|
||||
flagText = 'Vorschau in unter einer Woche, fertig in maximal einem Monat.'
|
||||
} else {
|
||||
tone = 'talk'
|
||||
flagTitle = 'Termin kurz abstimmen.'
|
||||
flagText = `Bei ${fmt(hrsNum)} Stunden legt Kenso den Fertigstellungstermin fest. Der Preis steht.`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sel, hrsNum, rateNum, netNum,
|
||||
mSum, askServer, monthlyItems, hasMonthly: monthlyItems.length > 0,
|
||||
payTitle, payText, flagTitle, flagText, tone,
|
||||
monthly: eur(mSum) + (askServer ? ' +' : ''),
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Termin --- */
|
||||
/* Feste Zeitfenster der Person, ergaenzt um Zeiten, die in WOMS bereits am
|
||||
Ticket stehen (jemand kann dort auch von Hand einen Termin setzen). */
|
||||
function slotsFor(personId, date) {
|
||||
const base = PEOPLE[personId].slots[date.getDay()] || []
|
||||
const key = dayKey(date)
|
||||
const email = PEOPLE[personId].email
|
||||
const extra = booked
|
||||
.filter((a) => a.date === key && a.employeeEmail === email && a.time && !base.includes(a.time))
|
||||
.map((a) => a.time)
|
||||
return extra.length ? [...new Set([...base, ...extra])].sort() : base
|
||||
}
|
||||
|
||||
function dayKey(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/* Ist die Zeit beim aktuell gewaehlten Mitarbeiter schon vergeben?
|
||||
'own' = der Termin dieses Kunden (der darf natuerlich stehen bleiben). */
|
||||
function slotTaken(dateKey, time) {
|
||||
const email = PEOPLE[state.person].email
|
||||
const hit = booked.find((a) => a.date === dateKey && a.time === time && a.employeeEmail === email)
|
||||
if (!hit) return null
|
||||
return hit.customerId && customer && hit.customerId === customer.id ? 'own' : 'other'
|
||||
}
|
||||
|
||||
function terminDays() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const list = []
|
||||
for (let i = 1; i <= 14; i += 1) {
|
||||
const d = new Date(today)
|
||||
d.setDate(today.getDate() + i)
|
||||
const key = dayKey(d)
|
||||
const slots = slotsFor(state.person, d)
|
||||
list.push({
|
||||
key,
|
||||
date: d,
|
||||
slots,
|
||||
free: slots.filter((s) => slotTaken(key, s) !== 'other'),
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
async function loadAppointments() {
|
||||
const days = terminDays()
|
||||
if (!days.length) return
|
||||
try {
|
||||
const { appointments } = await api(
|
||||
`/api/portal-admin/appointments?from=${days[0].key}&to=${days[days.length - 1].key}`
|
||||
)
|
||||
booked = appointments || []
|
||||
} catch (err) {
|
||||
console.warn('Belegte Termine konnten nicht geladen werden:', err.message)
|
||||
booked = []
|
||||
}
|
||||
}
|
||||
|
||||
function selectedDay() {
|
||||
return terminDays().find((d) => d.key === state.day && d.free.length) || null
|
||||
}
|
||||
|
||||
function longDay(date) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
function terminLine() {
|
||||
const day = selectedDay()
|
||||
if (!state.wantTermin || !day || !state.slot) return ''
|
||||
return `${longDay(day.date)} um ${state.slot} Uhr mit ${PEOPLE[state.person].name}`
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Zusammenfassung --- */
|
||||
function summaryText() {
|
||||
const t = totals()
|
||||
const lines = ['PROJEKTKALKULATION webklar', '']
|
||||
lines.push(`Kunde: ${customer?.name || '______________________'}`)
|
||||
if (customer?.code) lines.push(`Kundennr.: ${customer.code}`)
|
||||
if (customer?.email) lines.push(`E-Mail: ${customer.email}`)
|
||||
if (customer?.phone) lines.push(`Telefon: ${customer.phone}`)
|
||||
lines.push(`Datum: ${new Date().toLocaleDateString('de-DE')}`)
|
||||
lines.push('', 'UMFANG')
|
||||
|
||||
let cur = ''
|
||||
for (const x of t.sel.filter((s) => !s.cat.monthly)) {
|
||||
if (x.cat.name !== cur) {
|
||||
cur = x.cat.name
|
||||
lines.push('', `[${cur}]`)
|
||||
}
|
||||
lines.push(`- ${x.item.n}${x.q > 1 ? ` (${x.q}×)` : ''} – ${fmt(x.h)} Std`)
|
||||
}
|
||||
if (!t.sel.length) lines.push('(noch nichts gewählt)')
|
||||
|
||||
lines.push('')
|
||||
lines.push(`Summe: ${fmt(t.hrsNum)} Stunden`)
|
||||
lines.push(`Stundensatz: ${eur(t.rateNum)}`)
|
||||
lines.push(`Projektpreis: ${eur(t.netNum)} (Endpreis, ohne Umsatzsteuer)`)
|
||||
lines.push('', 'ZAHLUNG', `${t.payTitle} — ${t.payText}`)
|
||||
|
||||
if (t.hasMonthly) {
|
||||
lines.push('', 'LAUFEND')
|
||||
for (const x of t.monthlyItems) {
|
||||
lines.push(`- ${x.item.n}${x.q > 1 ? ` (${x.q}×)` : ''}: ${x.item.ask ? 'nach Last – rechnet Kenso aus' : `${eur(x.m)} pro Monat`}`)
|
||||
}
|
||||
lines.push(`Summe: ${eur(t.mSum)} pro Monat${t.askServer ? ' zzgl. Serveranteil – rechnet Kenso aus' : ''}`)
|
||||
}
|
||||
|
||||
lines.push('', 'HINWEIS', `${t.flagTitle} ${t.flagText}`)
|
||||
|
||||
const term = terminLine()
|
||||
if (term) {
|
||||
lines.push('', 'TERMIN', term)
|
||||
if (state.termNote) lines.push(`Thema: ${state.termNote}`)
|
||||
if (savedAppointment?.woid) lines.push(`Eingetragen in Ticket #${savedAppointment.woid}`)
|
||||
}
|
||||
if (state.notes) lines.push('', 'NOTIZEN', state.notes)
|
||||
if (state.portalEmail || state.portalPass) {
|
||||
lines.push('', 'KUNDENPORTAL (project.webklar.com)')
|
||||
if (state.portalEmail) lines.push(`E-Mail: ${state.portalEmail}`)
|
||||
if (state.portalPass) lines.push(`Passwort: ${state.portalPass}`)
|
||||
}
|
||||
lines.push('', `Alle Preise sind Endpreise. ${USt_HINWEIS}`)
|
||||
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 = 'Endpreis · gemäß § 19 UStG ohne Umsatzsteuer'
|
||||
el('hero-hrs').textContent = `${fmt(t.hrsNum)} Std`
|
||||
el('hero-rate').textContent = `Stundensatz ${eur(t.rateNum)}/Std`
|
||||
el('hero-monthly').textContent = t.hasMonthly ? t.monthly : '–'
|
||||
el('hero-monthly-sub').textContent = t.askServer
|
||||
? 'zzgl. Serveranteil, rechnet Kenso aus'
|
||||
: (t.hasMonthly ? 'Wartung, Hosting, Backups' : 'nichts Laufendes gewählt')
|
||||
|
||||
el('pay-title').textContent = t.payTitle
|
||||
el('pay-text').textContent = t.payText
|
||||
|
||||
const flag = el('flag')
|
||||
flag.className = `flag ${t.tone === 'neutral' ? '' : t.tone}`.trim()
|
||||
flag.innerHTML = `<b>${escapeHtml(t.flagTitle)}</b> ${escapeHtml(t.flagText)}`
|
||||
}
|
||||
|
||||
function renderTypeChips(t) {
|
||||
el('type-chips').innerHTML = CATS.map((cat) => {
|
||||
const on = Boolean(state.types[cat.id])
|
||||
const count = t.sel.filter((x) => x.cat.id === cat.id).length
|
||||
return `<button type="button" class="chip${on ? ' on' : ''}" data-act="toggle-type" data-id="${cat.id}" aria-pressed="${on}">
|
||||
<span class="mark">${on ? '✓' : ''}</span>${escapeHtml(cat.name)}${count ? `<span class="count">${count}</span>` : ''}
|
||||
</button>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function itemHours(cat, item) {
|
||||
const q = item.q ? (state.qty[item.id] || 1) : 1
|
||||
if (cat.monthly) return item.ask ? 'n. Last' : `${eur((item.m || 0) * q)}/Mon`
|
||||
return `${fmt((item.h || 0) * q)} Std`
|
||||
}
|
||||
|
||||
function renderItem(cat, item) {
|
||||
const on = Boolean(state.sel[item.id])
|
||||
const q = state.qty[item.id] || 1
|
||||
return `<div class="item${on ? ' on' : ''}" role="button" tabindex="0" aria-pressed="${on}" data-act="toggle-item" data-id="${item.id}">
|
||||
<span class="mark">${on ? '✓' : ''}</span>
|
||||
<span class="item-body">
|
||||
<b class="item-name">${escapeHtml(item.n)}</b>
|
||||
${item.d ? `<span class="item-desc">${escapeHtml(item.d)}</span>` : ''}
|
||||
<span class="item-hrs">${escapeHtml(itemHours(cat, item))}</span>
|
||||
${item.q ? `<span class="qty">
|
||||
<button type="button" data-act="qty" data-id="${item.id}" data-delta="-1" aria-label="Anzahl verringern">−</button>
|
||||
<input type="number" min="1" step="1" value="${q}" data-act="qty-set" data-id="${item.id}" aria-label="Anzahl">
|
||||
<button type="button" data-act="qty" data-id="${item.id}" data-delta="1" aria-label="Anzahl erhöhen">+</button>
|
||||
</span>` : ''}
|
||||
</span>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderSections() {
|
||||
const visible = CATS.filter((cat) => state.types[cat.id])
|
||||
el('empty-hint').hidden = visible.length > 0
|
||||
el('sections').innerHTML = visible.map((cat) => `
|
||||
<section class="block">
|
||||
<div class="section-head">
|
||||
<h2>${escapeHtml(cat.name)}</h2>
|
||||
<span class="section-tag${cat.kenso ? ' kenso' : ''}">${cat.kenso ? 'Kenso gibt frei' : 'darfst du selbst'}</span>
|
||||
</div>
|
||||
<p class="block-lead">${escapeHtml(cat.hint)}</p>
|
||||
${cat.groups.map((grp) => `
|
||||
<div class="group-label">${escapeHtml(grp.g)}</div>
|
||||
<div class="item-grid">${grp.items.map((item) => renderItem(cat, item)).join('')}</div>
|
||||
`).join('')}
|
||||
</section>`).join('')
|
||||
}
|
||||
|
||||
function renderChosen(t) {
|
||||
el('chosen-panel').hidden = t.sel.length === 0
|
||||
el('chosen-label').textContent = `Ausgewählt (${t.sel.length})`
|
||||
el('chosen-list').innerHTML = t.sel.map((x) => {
|
||||
const value = x.cat.monthly
|
||||
? (x.item.ask ? 'n. Last' : `${eur(x.m)}/M`)
|
||||
: `${fmt(x.h)} Std`
|
||||
return `<span class="chosen-chip">${escapeHtml(x.item.n)}${x.q > 1 ? ` ×${x.q}` : ''}<b>${escapeHtml(value)}</b></span>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function renderTermin() {
|
||||
el('termin-body').hidden = !state.wantTermin
|
||||
document.querySelectorAll('#want-seg button').forEach((btn) => {
|
||||
btn.classList.toggle('on', (btn.dataset.want === '1') === state.wantTermin)
|
||||
})
|
||||
if (!state.wantTermin) return
|
||||
|
||||
el('person-list').innerHTML = Object.keys(PEOPLE).map((id) => {
|
||||
const p = PEOPLE[id]
|
||||
const on = state.person === id
|
||||
return `<button type="button" class="person${on ? ' on' : ''}" data-act="person" data-id="${id}" aria-pressed="${on}">
|
||||
<span class="ava">${p.initial}</span>
|
||||
<span><b>${escapeHtml(p.name)}</b><span class="role">${escapeHtml(p.role)}</span></span>
|
||||
</button>`
|
||||
}).join('')
|
||||
|
||||
const days = terminDays()
|
||||
el('days-label').textContent = `Freie Tage — ${PEOPLE[state.person].name}`
|
||||
el('day-list').innerHTML = days.map((d) => {
|
||||
const free = d.free.length
|
||||
const on = state.day === d.key && free > 0
|
||||
return `<button type="button" class="day${on ? ' on' : ''}" data-act="day" data-key="${d.key}"${free ? '' : ' disabled'}>
|
||||
<span class="wd">${d.date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}</span>
|
||||
<span class="dm">${d.date.getDate()}.${d.date.getMonth() + 1}.</span>
|
||||
<span class="free">${free ? `${free} frei` : '—'}</span>
|
||||
</button>`
|
||||
}).join('')
|
||||
|
||||
const day = selectedDay()
|
||||
el('slot-wrap').hidden = !day
|
||||
if (day) {
|
||||
el('slots-label').textContent = `Uhrzeit am ${longDay(day.date)}`
|
||||
el('slot-list').innerHTML = day.slots.map((s) => {
|
||||
const taken = slotTaken(day.key, s)
|
||||
const on = state.slot === s
|
||||
const cls = ['slot', on ? 'on' : '', taken === 'own' && !on ? 'taken-own' : ''].filter(Boolean).join(' ')
|
||||
const label = taken === 'other' ? `${s} · belegt` : (taken === 'own' && !on ? `${s} · dieser Kunde` : s)
|
||||
return `<button type="button" class="${cls}" data-act="slot" data-t="${s}"
|
||||
${taken === 'other' ? 'disabled' : ''} aria-pressed="${on}">${label}</button>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
renderTermStatus()
|
||||
renderSaveButton()
|
||||
}
|
||||
|
||||
function renderSaveButton() {
|
||||
const btn = el('term-save')
|
||||
const hint = el('term-save-hint')
|
||||
if (!btn) return
|
||||
const ready = Boolean(terminLine()) && Boolean(customer)
|
||||
btn.disabled = !ready || btn.dataset.busy === '1'
|
||||
|
||||
if (hint.dataset.sticky === '1') return
|
||||
hint.className = 'term-save-hint'
|
||||
if (!customer) {
|
||||
hint.textContent = 'Ohne Kunde kann kein Ticket beschrieben werden — Seite über einen Lead öffnen.'
|
||||
} else if (!ready) {
|
||||
hint.textContent = 'Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.'
|
||||
} else {
|
||||
hint.textContent = `Trägt den Termin am Akquise-Ticket von ${customer.name} ein — damit steht er im Planboard.`
|
||||
}
|
||||
}
|
||||
|
||||
function renderTermStatus() {
|
||||
const box = el('term-status')
|
||||
const line = terminLine()
|
||||
if (line) {
|
||||
box.className = 'term-status fixed'
|
||||
box.innerHTML = `<b>Termin steht:</b> ${escapeHtml(line)}${state.termNote ? ` — Thema: ${escapeHtml(state.termNote)}` : ''}`
|
||||
} else {
|
||||
box.className = 'term-status'
|
||||
box.innerHTML = '<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.'
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const t = totals()
|
||||
renderNumbers(t)
|
||||
renderTypeChips(t)
|
||||
renderSections()
|
||||
renderChosen(t)
|
||||
renderTermin()
|
||||
saveState()
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Aktionen --- */
|
||||
function toggleItem(id) {
|
||||
if (!ITEM_BY_ID[id]) return
|
||||
state.sel[id] = !state.sel[id]
|
||||
render()
|
||||
}
|
||||
|
||||
function changeQty(id, delta) {
|
||||
state.qty[id] = Math.max(1, (state.qty[id] || 1) + delta)
|
||||
if (!state.sel[id]) state.sel[id] = true
|
||||
render()
|
||||
}
|
||||
|
||||
function setQty(id, value) {
|
||||
const n = Math.max(1, parseInt(value, 10) || 1)
|
||||
state.qty[id] = n
|
||||
render()
|
||||
}
|
||||
|
||||
/* Steht am Akquise-Ticket schon ein Termin, ist das die Wahrheit - der
|
||||
Zwischenspeicher im Browser hat dann das Nachsehen. */
|
||||
function adoptExistingAppointment() {
|
||||
const existing = booked.find((a) => a.customerId === customer.id)
|
||||
if (!existing) return
|
||||
savedAppointment = existing
|
||||
|
||||
const personId = Object.keys(PEOPLE).find((id) => PEOPLE[id].email === existing.employeeEmail)
|
||||
if (!personId) return
|
||||
|
||||
state.wantTermin = true
|
||||
state.person = personId
|
||||
state.day = existing.date
|
||||
state.slot = existing.time
|
||||
}
|
||||
|
||||
function clearSaveHint() {
|
||||
const hint = el('term-save-hint')
|
||||
if (hint) delete hint.dataset.sticky
|
||||
}
|
||||
|
||||
/* Termin am Akquise-Ticket eintragen -> erscheint im WOMS-Planboard. */
|
||||
async function saveTermin() {
|
||||
const day = selectedDay()
|
||||
if (!day || !state.slot || !customer) return
|
||||
|
||||
const btn = el('term-save')
|
||||
const hint = el('term-save-hint')
|
||||
btn.dataset.busy = '1'
|
||||
btn.disabled = true
|
||||
hint.dataset.sticky = '1'
|
||||
hint.className = 'term-save-hint'
|
||||
hint.textContent = 'Wird eingetragen…'
|
||||
|
||||
try {
|
||||
const { appointment } = await api(`/api/portal-admin/customers/${encodeURIComponent(customer.id)}/appointment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ date: day.key, time: state.slot, employeeEmail: PEOPLE[state.person].email }),
|
||||
})
|
||||
savedAppointment = appointment
|
||||
hint.className = 'term-save-hint done'
|
||||
const art = appointment.type && appointment.type.toLowerCase() !== 'akquise' ? `${appointment.type}-Ticket` : 'Ticket'
|
||||
hint.textContent = `Eingetragen in ${art} ${appointment.woid ? `#${appointment.woid}` : ''} – steht jetzt im Planboard.`
|
||||
await loadAppointments()
|
||||
} catch (err) {
|
||||
hint.className = 'term-save-hint error'
|
||||
hint.textContent = err.message
|
||||
} finally {
|
||||
delete btn.dataset.busy
|
||||
render()
|
||||
}
|
||||
}
|
||||
|
||||
function openSummary() {
|
||||
el('summary-text').value = summaryText()
|
||||
el('summary-dialog').hidden = false
|
||||
}
|
||||
|
||||
function closeSummary() {
|
||||
el('summary-dialog').hidden = true
|
||||
}
|
||||
|
||||
async function copySummary(btn) {
|
||||
const text = el('summary-text').value
|
||||
let ok = false
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ok = true
|
||||
} catch {
|
||||
// Fallback fuer Browser ohne Clipboard-API bzw. ohne HTTPS-Kontext
|
||||
const area = el('summary-text')
|
||||
area.removeAttribute('readonly')
|
||||
area.select()
|
||||
ok = document.execCommand('copy')
|
||||
area.setAttribute('readonly', '')
|
||||
}
|
||||
btn.textContent = ok ? 'Kopiert ✓' : 'Bitte manuell markieren'
|
||||
setTimeout(() => { btn.textContent = 'Kopieren' }, 2200)
|
||||
}
|
||||
|
||||
function resetSelection() {
|
||||
if (!confirm('Alle angekreuzten Positionen zurücksetzen? Notizen und Termin bleiben erhalten.')) return
|
||||
state.sel = {}
|
||||
state.qty = {}
|
||||
render()
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- Init --- */
|
||||
function bindEvents() {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target.closest('[data-act]')
|
||||
if (!target) return
|
||||
const act = target.dataset.act
|
||||
|
||||
// Klicks auf die Anzahl-Steuerung treffen zuerst deren eigenes data-act,
|
||||
// schalten die Karte also nicht mit um.
|
||||
switch (act) {
|
||||
case 'toggle-type':
|
||||
state.types[target.dataset.id] = !state.types[target.dataset.id]
|
||||
render()
|
||||
break
|
||||
case 'toggle-item':
|
||||
toggleItem(target.dataset.id)
|
||||
break
|
||||
case 'qty':
|
||||
changeQty(target.dataset.id, Number(target.dataset.delta))
|
||||
break
|
||||
case 'want':
|
||||
state.wantTermin = target.dataset.want === '1'
|
||||
clearSaveHint()
|
||||
render()
|
||||
break
|
||||
case 'person':
|
||||
state.person = target.dataset.id
|
||||
state.day = ''
|
||||
state.slot = ''
|
||||
clearSaveHint()
|
||||
render()
|
||||
break
|
||||
case 'day':
|
||||
state.day = target.dataset.key
|
||||
state.slot = ''
|
||||
clearSaveHint()
|
||||
render()
|
||||
break
|
||||
case 'slot':
|
||||
state.slot = target.dataset.t
|
||||
clearSaveHint()
|
||||
render()
|
||||
break
|
||||
case 'save-termin':
|
||||
saveTermin()
|
||||
break
|
||||
case 'open-summary': openSummary(); break
|
||||
case 'close-summary': closeSummary(); break
|
||||
case 'copy': copySummary(target); break
|
||||
case 'reset': resetSelection(); break
|
||||
default: break
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('change', (event) => {
|
||||
const input = event.target.closest('[data-act="qty-set"]')
|
||||
if (!input) return
|
||||
setQty(input.dataset.id, input.value)
|
||||
})
|
||||
|
||||
// Karten sind divs mit role=button -> Tastatur nachruesten.
|
||||
// Im Anzahl-Feld und auf den +/- Buttons darf das nicht greifen.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
if (event.target.closest('input, textarea, button')) return
|
||||
const card = event.target.closest('[data-act="toggle-item"]')
|
||||
if (!card) return
|
||||
event.preventDefault()
|
||||
toggleItem(card.dataset.id)
|
||||
})
|
||||
|
||||
el('term-note').addEventListener('input', (event) => {
|
||||
state.termNote = event.target.value
|
||||
renderTermStatus()
|
||||
saveState()
|
||||
})
|
||||
el('notes').addEventListener('input', (event) => {
|
||||
state.notes = event.target.value
|
||||
saveState()
|
||||
})
|
||||
el('portal-email').addEventListener('input', (event) => {
|
||||
state.portalEmail = event.target.value
|
||||
saveState()
|
||||
})
|
||||
el('portal-pass').addEventListener('input', (event) => {
|
||||
state.portalPass = event.target.value
|
||||
saveState()
|
||||
})
|
||||
|
||||
el('summary-dialog').addEventListener('click', (event) => {
|
||||
if (event.target === el('summary-dialog')) closeSummary()
|
||||
})
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !el('summary-dialog').hidden) closeSummary()
|
||||
})
|
||||
}
|
||||
|
||||
async function initKalkulation() {
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
if (!me.authenticated || me.role !== 'admin') {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = new URLSearchParams(window.location.search).get('customer') || ''
|
||||
if (customerId) {
|
||||
storageKey = `wk-kalkulation:${customerId}`
|
||||
try {
|
||||
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`)
|
||||
customer = data.customer
|
||||
} catch (err) {
|
||||
console.warn('Kunde konnte nicht geladen werden:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
restoreState()
|
||||
await loadAppointments()
|
||||
|
||||
if (customer) {
|
||||
el('hero-kicker').textContent = `Angebot für ${customer.name}`
|
||||
if (!state.portalEmail) state.portalEmail = customer.email || ''
|
||||
adoptExistingAppointment()
|
||||
}
|
||||
|
||||
el('term-note').value = state.termNote
|
||||
el('notes').value = state.notes
|
||||
el('portal-email').value = state.portalEmail
|
||||
el('portal-pass').value = state.portalPass
|
||||
|
||||
bindEvents()
|
||||
render()
|
||||
}
|
||||
|
||||
window.initKalkulation = initKalkulation
|
||||
Reference in new issue
Block a user