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
+223
-3995
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
+3
-15
@@ -570,10 +570,9 @@ async function initLoginPage() {
|
|||||||
/* --------------------------------------------------------------- View-as --- */
|
/* --------------------------------------------------------------- View-as --- */
|
||||||
function renderViewAsBanner(me) {
|
function renderViewAsBanner(me) {
|
||||||
const banner = document.getElementById('viewas-banner')
|
const banner = document.getElementById('viewas-banner')
|
||||||
if (!banner || !me.viewAs) return
|
if (!banner || !me.viewAs || !me.viewId) return
|
||||||
banner.classList.remove('hidden')
|
banner.classList.remove('hidden')
|
||||||
// Kundenansicht aus dem Ticketsystem (mit Kennung): Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
|
// Kundenansicht aus dem Ticketsystem: Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
|
||||||
if (me.viewId) {
|
|
||||||
banner.innerHTML = `
|
banner.innerHTML = `
|
||||||
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
|
<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>
|
<button type="button" id="exit-viewas-btn">Beenden</button>
|
||||||
@@ -582,17 +581,6 @@ function renderViewAsBanner(me) {
|
|||||||
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
|
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
|
||||||
window.location.href = redirect || 'https://ticket.webklar.com/customers'
|
window.location.href = redirect || 'https://ticket.webklar.com/customers'
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
// Alte Ansicht "Als Kunde ansehen" aus dem Admin-Panel (bis das Panel abgeschaltet ist)
|
|
||||||
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>
|
|
||||||
`
|
|
||||||
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
|
|
||||||
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
|
|
||||||
window.location.href = '/admin.html'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------- Rechnungen --- */
|
/* ------------------------------------------------------------- Rechnungen --- */
|
||||||
@@ -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,
|
/* 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) {
|
function setAvatar(el, initials, seed) {
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.textContent = initials
|
el.textContent = initials
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Admin-Panel, siehe chat-composer.js).
|
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Ticketsystem, siehe chat-composer.js).
|
||||||
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, admin.html setzt Tuerkis. */
|
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, das Ticketsystem setzt eigene (styles/chat.css). */
|
||||||
:root {
|
:root {
|
||||||
--chat-accent: #9d3dfe;
|
--chat-accent: #9d3dfe;
|
||||||
--chat-accent-hover: #8a25f0;
|
--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.
|
ChatComposer.create({...}) baut die Eingabezeile, ChatComposer.renderMessage(...) eine Sprechblase.
|
||||||
Erlaubte Endungen und Grenzen prueft der Server noch einmal (server/services/chatAttachments.js). */
|
Erlaubte Endungen und Grenzen prueft der Server noch einmal (server/services/chatAttachments.js). */
|
||||||
(function () {
|
(function () {
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sprechblase: Anhaenge, Text, Absender/Zeit.
|
* 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 }) {
|
function renderMessage(msg, { mine, meta, fileUrl }) {
|
||||||
const atts = Array.isArray(msg.attachments) ? msg.attachments : []
|
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
|
|
||||||
@@ -13,7 +13,6 @@ import authRoutes from './routes/auth.js'
|
|||||||
import projectsRoutes from './routes/projects.js'
|
import projectsRoutes from './routes/projects.js'
|
||||||
import featuresRoutes from './routes/features.js'
|
import featuresRoutes from './routes/features.js'
|
||||||
import customerDataRoutes from './routes/customerData.js'
|
import customerDataRoutes from './routes/customerData.js'
|
||||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
|
||||||
import chatRoutes from './routes/chat.js'
|
import chatRoutes from './routes/chat.js'
|
||||||
import aboRoutes from './routes/abo.js'
|
import aboRoutes from './routes/abo.js'
|
||||||
import funktionenRoutes from './routes/funktionen.js'
|
import funktionenRoutes from './routes/funktionen.js'
|
||||||
@@ -33,12 +32,10 @@ import adminServicesRoutes from './routes/admin/services.js'
|
|||||||
import adminCustomersRoutes from './routes/admin/customers.js'
|
import adminCustomersRoutes from './routes/admin/customers.js'
|
||||||
import adminEmployeesRoutes from './routes/admin/employees.js'
|
import adminEmployeesRoutes from './routes/admin/employees.js'
|
||||||
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
||||||
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
|
|
||||||
import newsRoutes from './routes/news.js'
|
import newsRoutes from './routes/news.js'
|
||||||
import publicNewsRoutes from './routes/publicNews.js'
|
import publicNewsRoutes from './routes/publicNews.js'
|
||||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||||
import { analyseLegalRouter } from './services/analyseLegal.js'
|
import { analyseLegalRouter } from './services/analyseLegal.js'
|
||||||
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
|
|
||||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||||
import teamRoutes from './routes/team/index.js'
|
import teamRoutes from './routes/team/index.js'
|
||||||
import { corsMiddleware } from './middleware/cors.js'
|
import { corsMiddleware } from './middleware/cors.js'
|
||||||
@@ -74,7 +71,6 @@ app.use('/api/chat', chatRoutes)
|
|||||||
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
|
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
|
||||||
app.use('/api/abo', aboRoutes)
|
app.use('/api/abo', aboRoutes)
|
||||||
app.use('/api/funktionen', funktionenRoutes)
|
app.use('/api/funktionen', funktionenRoutes)
|
||||||
app.use('/api/portal-admin', portalAdminRoutes)
|
|
||||||
app.use('/api/team', teamRoutes)
|
app.use('/api/team', teamRoutes)
|
||||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||||
app.use('/api/admin/website-projects', adminPreviewRoutes)
|
app.use('/api/admin/website-projects', adminPreviewRoutes)
|
||||||
@@ -83,8 +79,6 @@ app.use('/api/admin/services', adminServicesRoutes)
|
|||||||
app.use('/api/admin/customers', adminCustomersRoutes)
|
app.use('/api/admin/customers', adminCustomersRoutes)
|
||||||
app.use('/api/admin/employees', adminEmployeesRoutes)
|
app.use('/api/admin/employees', adminEmployeesRoutes)
|
||||||
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
||||||
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
|
|
||||||
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
|
|
||||||
app.use('/api/admin/analyse', adminAnalyseRoutes)
|
app.use('/api/admin/analyse', adminAnalyseRoutes)
|
||||||
app.use('/api/admin/finanzen', adminFinanzenRoutes)
|
app.use('/api/admin/finanzen', adminFinanzenRoutes)
|
||||||
app.use('/webhook', giteaWebhookRoutes)
|
app.use('/webhook', giteaWebhookRoutes)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { VIEW_EXPIRED_MESSAGE, endKundenansicht, isExpired, isKundenansicht, log
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3):
|
* Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3):
|
||||||
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg
|
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg; ebenso alte Ansichten ohne viewId ("Als Kunde ansehen" aus
|
||||||
|
* dem frueheren Admin-Panel)
|
||||||
* 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht)
|
* 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht)
|
||||||
* 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht.
|
* 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht.
|
||||||
* Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen.
|
* Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen.
|
||||||
* Alte Ansichten aus dem Admin-Panel (ohne viewId) duerfen nichts schreiben ausser Ab- und Anmelden.
|
|
||||||
*/
|
*/
|
||||||
export const KUNDENANSICHT_ALLOWED = [
|
export const KUNDENANSICHT_ALLOWED = [
|
||||||
['POST', /^\/api\/news\/?$/],
|
['POST', /^\/api\/news\/?$/],
|
||||||
@@ -19,13 +19,9 @@ export const KUNDENANSICHT_ALLOWED = [
|
|||||||
['POST', /^\/api\/auth\/logout\/?$/],
|
['POST', /^\/api\/auth\/logout\/?$/],
|
||||||
['POST', /^\/api\/auth\/login\/?$/],
|
['POST', /^\/api\/auth\/login\/?$/],
|
||||||
]
|
]
|
||||||
const LEGACY_ALLOWED = [
|
|
||||||
['POST', /^\/api\/auth\/logout\/?$/],
|
|
||||||
['POST', /^\/api\/auth\/login\/?$/],
|
|
||||||
]
|
|
||||||
|
|
||||||
// Eigene Pruefung (Ticketsystem-JWT, Admin-Sitzung) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
|
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
|
||||||
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/portal-admin/']
|
const PASS_PREFIXES = ['/api/team/', '/api/admin/']
|
||||||
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
|
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
|
||||||
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
|
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
|
||||||
|
|
||||||
@@ -41,20 +37,20 @@ export async function kundenansichtGuard(req, res, next) {
|
|||||||
if (!session) return next()
|
if (!session) return next()
|
||||||
|
|
||||||
const current = isKundenansicht(session)
|
const current = isKundenansicht(session)
|
||||||
if (current && isExpired(session)) {
|
// Abgelaufen oder alte Ansicht ohne Kennung (frueheres Admin-Panel): beenden, nichts mehr lesen oder schreiben
|
||||||
|
if (session.viewAs && (!current || isExpired(session))) {
|
||||||
endKundenansicht(res)
|
endKundenansicht(res)
|
||||||
await logOnce(session, 'abgelaufen')
|
if (current) await logOnce(session, 'abgelaufen')
|
||||||
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
|
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
|
||||||
}
|
}
|
||||||
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht, alte
|
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht,
|
||||||
// Admin-Ansicht, Kunden-Login): keine fremden Daten in diesen Tab
|
// Kunden-Login): keine fremden Daten in diesen Tab
|
||||||
const header = req.get('X-Kundenansicht')
|
const header = req.get('X-Kundenansicht')
|
||||||
if (header && header !== session.viewId) return replaced(res, session)
|
if (header && header !== session.viewId) return replaced(res, session)
|
||||||
if (!session.viewAs) return next()
|
if (!session.viewAs) return next()
|
||||||
if (READ_METHODS.includes(req.method)) return next()
|
if (READ_METHODS.includes(req.method)) return next()
|
||||||
if (current && !header) return replaced(res, session)
|
if (!header) return replaced(res, session)
|
||||||
|
|
||||||
const list = current ? KUNDENANSICHT_ALLOWED : LEGACY_ALLOWED
|
if (KUNDENANSICHT_ALLOWED.some(([method, re]) => method === req.method && re.test(pathname))) return next()
|
||||||
if (list.some(([method, re]) => method === req.method && re.test(pathname))) return next()
|
|
||||||
return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' })
|
return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' })
|
||||||
}
|
}
|
||||||
@@ -58,16 +58,6 @@ export async function requireSession(req, res, next) {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin-Session: Ticketsystem-Mitarbeiter (Appwrite-Label "admin") im Portal. */
|
|
||||||
export function requireAdminSession(req, res, next) {
|
|
||||||
const session = parsePortalSession(req)
|
|
||||||
if (!session?.appwriteUserId || session.role !== 'admin') {
|
|
||||||
return res.status(401).json({ error: 'Admin-Anmeldung erforderlich' })
|
|
||||||
}
|
|
||||||
req.session = session
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSessionCustomerId(req) {
|
export function getSessionCustomerId(req) {
|
||||||
return req.session?.customerId
|
return req.session?.customerId
|
||||||
}
|
}
|
||||||
@@ -216,7 +216,7 @@ router.get('/', requireSession, async (req, res) => {
|
|||||||
router.post('/checkout', requireSession, async (req, res) => {
|
router.post('/checkout', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
|
||||||
}
|
}
|
||||||
const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {}
|
const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {}
|
||||||
if (!PRODUCT_KEYS.includes(productKey)) {
|
if (!PRODUCT_KEYS.includes(productKey)) {
|
||||||
@@ -280,7 +280,7 @@ router.post('/checkout', requireSession, async (req, res) => {
|
|||||||
router.post('/portal', requireSession, async (req, res) => {
|
router.post('/portal', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
|
||||||
}
|
}
|
||||||
const url = await createBillingPortalUrl(getSessionCustomerId(req))
|
const url = await createBillingPortalUrl(getSessionCustomerId(req))
|
||||||
return res.json({ url })
|
return res.json({ url })
|
||||||
@@ -297,7 +297,7 @@ router.post('/portal', requireSession, async (req, res) => {
|
|||||||
router.post('/storage/preview', requireSession, async (req, res) => {
|
router.post('/storage/preview', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
|
||||||
}
|
}
|
||||||
return res.json(await previewStorageChange(getSessionCustomerId(req), req.body?.units))
|
return res.json(await previewStorageChange(getSessionCustomerId(req), req.body?.units))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -314,7 +314,7 @@ router.post('/storage/preview', requireSession, async (req, res) => {
|
|||||||
router.post('/storage', requireSession, async (req, res) => {
|
router.post('/storage', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann der Zusatzspeicher nicht geändert werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann der Zusatzspeicher nicht geändert werden.' })
|
||||||
}
|
}
|
||||||
const customerId = getSessionCustomerId(req)
|
const customerId = getSessionCustomerId(req)
|
||||||
const { units, acceptTerms, termsVersion } = req.body || {}
|
const { units, acceptTerms, termsVersion } = req.body || {}
|
||||||
@@ -350,7 +350,7 @@ router.post('/storage', requireSession, async (req, res) => {
|
|||||||
router.post('/switch/preview', requireSession, async (req, res) => {
|
router.post('/switch/preview', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
|
||||||
}
|
}
|
||||||
return res.json(await previewSwitch(getSessionCustomerId(req)))
|
return res.json(await previewSwitch(getSessionCustomerId(req)))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -368,7 +368,7 @@ router.post('/switch/preview', requireSession, async (req, res) => {
|
|||||||
router.post('/switch', requireSession, async (req, res) => {
|
router.post('/switch', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann nicht gewechselt werden.' })
|
||||||
}
|
}
|
||||||
const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {}
|
const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {}
|
||||||
if (acceptTerms !== true) {
|
if (acceptTerms !== true) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { withLocks } from '../../services/keyedLock.js'
|
|||||||
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
|
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Funktionen (News-Pinnwand) je Kundenprojekt im Admin-Panel.
|
* Funktionen (News-Pinnwand) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
|
||||||
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/funktionen, dort hinter requireAdminSession.
|
* Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt.
|
||||||
*/
|
*/
|
||||||
export const funktionenAdminRouter = Router()
|
export const funktionenAdminRouter = Router()
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
import { config } from '../../config.js'
|
|
||||||
import { appwriteAccountFromJwt } from '../../middleware/staffJwt.js'
|
|
||||||
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
|
|
||||||
import { resolveStaffAccount } from '../../services/staffAccount.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wechsel Ticketsystem -> Projekt-Admin ohne zweiten Login. Wie requireAppwriteStaff wird das JWT
|
|
||||||
* ueber /account geprueft (eine Sitzung ohne zweiten Faktor bekommt dort 401), aber ohne Label-Pflicht:
|
|
||||||
* jeder Mitarbeiter darf rueber, wie beim normalen Portal-Login.
|
|
||||||
*/
|
|
||||||
router.post('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const user = await appwriteAccountFromJwt(req)
|
|
||||||
if (!user?.$id) return res.status(401).json({ error: 'Bitte im Ticketsystem neu anmelden.' })
|
|
||||||
|
|
||||||
const staff = await resolveStaffAccount(user.$id, user.email || '', user)
|
|
||||||
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
|
|
||||||
|
|
||||||
const token = createStaffPreviewToken({
|
|
||||||
purpose: 'switch',
|
|
||||||
userId: user.$id,
|
|
||||||
email: staff.email,
|
|
||||||
name: staff.name,
|
|
||||||
isAdmin: staff.isAdmin,
|
|
||||||
})
|
|
||||||
return res.json({
|
|
||||||
openUrl: `https://${config.preview.baseHost}/api/auth/staff-switch?token=${encodeURIComponent(token)}`,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ error: err.message || 'Wechsel fehlgeschlagen' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
import { signFileLink } from '../services/signedLinks.js'
|
import { signFileLink } from '../services/signedLinks.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat des Teams mit den Kunden. Eingehaengt im Admin-Panel (/api/portal-admin, Cookie) und im Team-Eingang des
|
* Chat des Teams mit den Kunden. Eingehaengt im Team-Eingang des Ticketsystems (/api/team, JWT); requireStaffJwt
|
||||||
* Ticketsystems (/api/team, JWT); beide liefern req.session mit appwriteUserId und Namen des Mitarbeiters.
|
* liefert req.session mit appwriteUserId und Namen des Mitarbeiters.
|
||||||
*/
|
*/
|
||||||
export const chatAdminRouter = Router()
|
export const chatAdminRouter = Router()
|
||||||
const router = chatAdminRouter
|
const router = chatAdminRouter
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ async function openOrderSession(doc) {
|
|||||||
router.post('/news/checkout', requireSession, async (req, res) => {
|
router.post('/news/checkout', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
|
||||||
}
|
}
|
||||||
const { projectId, consent } = req.body || {}
|
const { projectId, consent } = req.body || {}
|
||||||
if (consent !== true) {
|
if (consent !== true) {
|
||||||
@@ -182,7 +182,7 @@ const analyseGuard = [requireSession, requireAnalyseEnabled]
|
|||||||
router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
|
||||||
}
|
}
|
||||||
const { projectId, accentColor, consent } = req.body || {}
|
const { projectId, accentColor, consent } = req.body || {}
|
||||||
const { doc } = await activateAnalyse({
|
const { doc } = await activateAnalyse({
|
||||||
@@ -202,7 +202,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
|||||||
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
|
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
|
||||||
}
|
}
|
||||||
const { doc } = await deactivateAnalyse({
|
const { doc } = await deactivateAnalyse({
|
||||||
customerId: getSessionCustomerId(req),
|
customerId: getSessionCustomerId(req),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
|||||||
import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js'
|
import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
|
* Individuelle Abo-Angebote im Ticketsystem (Kunden -> Abo-Angebote, Kunden-Reiter "Abo & Angebote").
|
||||||
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/offers, dort hinter requireAdminSession.
|
* Eingehaengt in routes/team/index.js unter /api/team/offers, dort hinter requireStaffJwt.
|
||||||
*/
|
*/
|
||||||
export const offersAdminRouter = Router()
|
export const offersAdminRouter = Router()
|
||||||
|
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
import { config } from '../config.js'
|
|
||||||
import {
|
|
||||||
parsePortalSession,
|
|
||||||
requireAdminSession,
|
|
||||||
setPortalSession,
|
|
||||||
} from '../middleware/session.js'
|
|
||||||
import {
|
|
||||||
getDocument,
|
|
||||||
listDocuments,
|
|
||||||
updateDocument,
|
|
||||||
Query,
|
|
||||||
} from '../services/appwriteAdmin.js'
|
|
||||||
import { listAppointments, saveAppointment } from '../services/appointments.js'
|
|
||||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
|
||||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
|
||||||
import { chatAdminRouter, customerNameMap } from './chatAdmin.js'
|
|
||||||
import { funktionenAdminRouter } from './admin/funktionenAdmin.js'
|
|
||||||
import { offersAdminRouter } from './offers.js'
|
|
||||||
import { storageTasksAdminRouter } from './storageTasks.js'
|
|
||||||
import { takeoverAdminRouter } from './takeoverCredits.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.use(requireAdminSession)
|
|
||||||
|
|
||||||
// Uebernahmegutschriften: Pruefung und Entscheidung (Tab "Gutschriften")
|
|
||||||
router.use('/takeover-credits', takeoverAdminRouter)
|
|
||||||
// Individuelle Abo-Angebote (Tab "Abo-Angebote")
|
|
||||||
router.use('/offers', offersAdminRouter)
|
|
||||||
// Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher")
|
|
||||||
router.use('/storage-tasks', storageTasksAdminRouter)
|
|
||||||
// Funktionen (News-Pinnwand) je Kundenprojekt aktivieren/abschalten (Kundenliste, Zeile "Funktionen")
|
|
||||||
router.use('/funktionen', funktionenAdminRouter)
|
|
||||||
|
|
||||||
function norm(value) {
|
|
||||||
return String(value || '').toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDeployed(project) {
|
|
||||||
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Übersicht --
|
|
||||||
router.get('/overview', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const [projects, customers, access] = await Promise.all([
|
|
||||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
|
||||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
|
||||||
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
|
|
||||||
])
|
|
||||||
const nameById = customerNameMap(customers)
|
|
||||||
|
|
||||||
const stageOf = (c) => {
|
|
||||||
const s = norm(c.customerStatus)
|
|
||||||
if (s === 'lead') return 'lead'
|
|
||||||
if (s === 'lost') return 'lost'
|
|
||||||
return 'customer'
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentLogins = access
|
|
||||||
.filter((a) => a.lastLoginAt)
|
|
||||||
.sort((a, b) => String(b.lastLoginAt).localeCompare(String(a.lastLoginAt)))
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((a) => ({
|
|
||||||
customerName: nameById[a.customerId] || a.customerId,
|
|
||||||
lastLoginAt: a.lastLoginAt,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const recentProjects = [...projects]
|
|
||||||
.sort((a, b) =>
|
|
||||||
String(b.updatedAt || b.$updatedAt || '').localeCompare(String(a.updatedAt || a.$updatedAt || ''))
|
|
||||||
)
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((p) => ({
|
|
||||||
projectName: p.projectName || p.subdomain || p.repoFullName || '',
|
|
||||||
subdomain: p.subdomain || '',
|
|
||||||
status: p.status || '',
|
|
||||||
customerName: p.customerId ? nameById[p.customerId] || '' : '',
|
|
||||||
updatedAt: p.updatedAt || p.$updatedAt || '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
projects: {
|
|
||||||
total: projects.length,
|
|
||||||
deployed: projects.filter(isDeployed).length,
|
|
||||||
withoutWebsite: projects.filter((p) => !p.subdomain).length,
|
|
||||||
assigned: projects.filter((p) => p.customerId).length,
|
|
||||||
},
|
|
||||||
customers: {
|
|
||||||
total: customers.length,
|
|
||||||
leads: customers.filter((c) => stageOf(c) === 'lead').length,
|
|
||||||
fixed: customers.filter((c) => stageOf(c) === 'customer').length,
|
|
||||||
portalEnabled: customers.filter((c) => c.portalAccessEnabled).length,
|
|
||||||
},
|
|
||||||
recentLogins,
|
|
||||||
recentProjects,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ error: err.message || 'Übersicht konnte nicht geladen werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ------------------------------------------------------------- Projektsuche --
|
|
||||||
router.get('/projects', async (req, res) => {
|
|
||||||
const search = norm(req.query.search)
|
|
||||||
try {
|
|
||||||
const [projects, customers] = await Promise.all([
|
|
||||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
|
||||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
|
||||||
])
|
|
||||||
const nameById = customerNameMap(customers)
|
|
||||||
|
|
||||||
let items = projects
|
|
||||||
.filter((p) => !p.archived)
|
|
||||||
.map((p) => ({
|
|
||||||
id: p.$id,
|
|
||||||
projectName: p.projectName || '',
|
|
||||||
subdomain: p.subdomain || '',
|
|
||||||
previewUrl: p.previewUrl || '',
|
|
||||||
liveDomain: p.liveDomain || '',
|
|
||||||
status: p.status || '',
|
|
||||||
provisioningStatus: p.provisioningStatus || '',
|
|
||||||
projectType: p.projectType || (p.subdomain ? 'website' : 'repo'),
|
|
||||||
giteaRepoUrl: p.giteaRepoUrl || '',
|
|
||||||
repoFullName: p.repoFullName || '',
|
|
||||||
customerId: p.customerId || '',
|
|
||||||
customerName: p.customerId ? nameById[p.customerId] || p.customerId : '',
|
|
||||||
updatedAt: p.updatedAt || p.$updatedAt || '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
items = items.filter((p) =>
|
|
||||||
[p.projectName, p.subdomain, p.repoFullName, p.customerName, p.status].some((v) =>
|
|
||||||
norm(v).includes(search)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
|
||||||
return res.json({ projects: items })
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ error: err.message || 'Projekte konnten nicht geladen werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// -------------------------------------------------------------- Kundenliste --
|
|
||||||
router.get('/customers', async (req, res) => {
|
|
||||||
const search = norm(req.query.search)
|
|
||||||
try {
|
|
||||||
const [customers, access, projects] = await Promise.all([
|
|
||||||
listDocuments(config.collections.customers, [Query.orderAsc('name'), Query.limit(500)]),
|
|
||||||
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
|
|
||||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
|
||||||
])
|
|
||||||
|
|
||||||
const lastLoginByCustomer = {}
|
|
||||||
for (const a of access) {
|
|
||||||
if (a.customerId && a.lastLoginAt) lastLoginByCustomer[a.customerId] = a.lastLoginAt
|
|
||||||
}
|
|
||||||
const projectCount = {}
|
|
||||||
for (const p of projects) {
|
|
||||||
if (p.customerId) projectCount[p.customerId] = (projectCount[p.customerId] || 0) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
let items = customers
|
|
||||||
.filter((c) => !c.archived)
|
|
||||||
.map((c) => ({
|
|
||||||
id: c.$id,
|
|
||||||
code: c.code || '',
|
|
||||||
name: c.name || '',
|
|
||||||
email: c.email || '',
|
|
||||||
phone: c.phone || '',
|
|
||||||
location: c.location || '',
|
|
||||||
customerStatus: c.customerStatus || '',
|
|
||||||
portalAccessEnabled: Boolean(c.portalAccessEnabled),
|
|
||||||
invoiceNinjaLinked: Boolean(c.invoiceNinjaClientId),
|
|
||||||
lastLoginAt: lastLoginByCustomer[c.$id] || '',
|
|
||||||
projectCount: projectCount[c.$id] || 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
items = items.filter((c) =>
|
|
||||||
[c.code, c.name, c.email, c.location].some((v) => norm(v).includes(search))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({ customers: items })
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ error: err.message || 'Kunden konnten nicht geladen werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------- Termine --
|
|
||||||
// Termine haengen am Akquise-Ticket (workorders) und werden vom WOMS-Planboard
|
|
||||||
// gelesen. Siehe server/services/appointments.js.
|
|
||||||
router.get('/appointments', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const appointments = await listAppointments({
|
|
||||||
from: String(req.query.from || ''),
|
|
||||||
to: String(req.query.to || ''),
|
|
||||||
})
|
|
||||||
return res.json({ appointments })
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(err.status || 500).json({ error: err.message || 'Termine konnten nicht geladen werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/customers/:customerId/appointment', async (req, res) => {
|
|
||||||
const { date, time, employeeEmail } = req.body || {}
|
|
||||||
try {
|
|
||||||
const appointment = await saveAppointment({
|
|
||||||
customerId: req.params.customerId,
|
|
||||||
employeeEmail,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
})
|
|
||||||
return res.json({ success: true, appointment })
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(err.status || 500).json({ error: err.message || 'Termin konnte nicht gespeichert werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// --------------------------------------------- Einzelner Kunde (Stammdaten) --
|
|
||||||
// Fuer die Rechnungsanalyse (kalkulation.html), die den Lead vorbefuellt.
|
|
||||||
router.get('/customers/:customerId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const customer = await getDocument(config.collections.customers, req.params.customerId)
|
|
||||||
return res.json({
|
|
||||||
customer: {
|
|
||||||
id: customer.$id,
|
|
||||||
code: customer.code || '',
|
|
||||||
name: customer.name || '',
|
|
||||||
email: customer.email || '',
|
|
||||||
phone: customer.phone || '',
|
|
||||||
location: customer.location || '',
|
|
||||||
customerStatus: customer.customerStatus || '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
const status = err.status || 500
|
|
||||||
return res.status(status).json({ error: err.message || 'Kunde konnte nicht geladen werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ------------------------------------------- Kundennummer/Stammdaten pflegen --
|
|
||||||
router.patch('/customers/:customerId', async (req, res) => {
|
|
||||||
const { code, location, phone } = req.body || {}
|
|
||||||
const data = { updatedAt: new Date().toISOString() }
|
|
||||||
if (code !== undefined) data.code = String(code).trim()
|
|
||||||
if (location !== undefined) data.location = String(location).trim()
|
|
||||||
if (phone !== undefined) data.phone = String(phone).trim()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await updateDocument(config.collections.customers, req.params.customerId, data)
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
customer: { id: updated.$id, code: updated.code || '', name: updated.name || '' },
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
const status = err.status || 500
|
|
||||||
return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// -------------------------------------- Aktivität + Rechnungen eines Kunden --
|
|
||||||
router.get('/customers/:customerId/activity', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tickets = await getCustomerActivity(req.params.customerId, { ticketLimit: 50 })
|
|
||||||
return res.json({ tickets })
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/customers/:customerId/invoices', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const customer = await getDocument(config.collections.customers, req.params.customerId)
|
|
||||||
if (!customer.invoiceNinjaClientId) {
|
|
||||||
return res.json({ linked: false, invoices: [] })
|
|
||||||
}
|
|
||||||
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId, { includeDrafts: true })
|
|
||||||
return res.json({ linked: true, invoices })
|
|
||||||
} catch (err) {
|
|
||||||
const status = err.status || 500
|
|
||||||
return res.status(status).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Chat (Tab "Chat"): eigener Router, auch im Team-Eingang des Ticketsystems eingehaengt
|
|
||||||
router.use(chatAdminRouter)
|
|
||||||
|
|
||||||
// ------------------------------------------------------- Als Kunde ansehen --
|
|
||||||
router.post('/view-as/:customerId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
|
||||||
if (!customer) {
|
|
||||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = req.session
|
|
||||||
setPortalSession(res, {
|
|
||||||
role: 'admin',
|
|
||||||
viewAs: true,
|
|
||||||
isAdmin: session.isAdmin,
|
|
||||||
appwriteUserId: session.appwriteUserId,
|
|
||||||
adminName: session.adminName || session.name || 'Admin',
|
|
||||||
adminEmail: session.adminEmail || session.email || '',
|
|
||||||
customerId: customer.$id,
|
|
||||||
name: customer.name || '',
|
|
||||||
email: customer.email || '',
|
|
||||||
})
|
|
||||||
return res.json({ success: true, customer: { id: customer.$id, name: customer.name || '' } })
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ error: err.message || 'Ansicht konnte nicht gewechselt werden' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/exit-view-as', (req, res) => {
|
|
||||||
const session = parsePortalSession(req) || req.session
|
|
||||||
setPortalSession(res, {
|
|
||||||
role: 'admin',
|
|
||||||
isAdmin: session.isAdmin,
|
|
||||||
appwriteUserId: session.appwriteUserId,
|
|
||||||
name: session.adminName || session.name || 'Admin',
|
|
||||||
email: session.adminEmail || session.email || '',
|
|
||||||
})
|
|
||||||
return res.json({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -8,10 +8,10 @@ import { PRODUCT_KEYS, productLabel } from '../services/products.js'
|
|||||||
import { ACTIVE_STATUSES } from '../services/stripeBilling.js'
|
import { ACTIVE_STATUSES } from '../services/stripeBilling.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-Reiter "E-Mail-Speicher" (Task 13): offene Zusatzspeicher-Aufgaben fuers Team (Postfachgrenze in
|
* "E-Mail-Speicher" im Ticketsystem (Kunden -> E-Mail-Speicher): offene Zusatzspeicher-Aufgaben fuers Team
|
||||||
* konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen der letzten
|
* (Postfachgrenze in konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen
|
||||||
* Wochen. Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/storage-tasks, dort hinter
|
* der letzten Wochen. Eingehaengt in routes/team/index.js unter /api/team/storage-tasks, dort hinter
|
||||||
* requireAdminSession.
|
* requireStaffJwt.
|
||||||
*
|
*
|
||||||
* Jede Aufgabe traegt ihre Art (details.kind, storageTaskKind): "einrichten" = jetzt auf das Ziel setzen,
|
* Jede Aufgabe traegt ihre Art (details.kind, storageTaskKind): "einrichten" = jetzt auf das Ziel setzen,
|
||||||
* "periodenende" = zum Faelligkeitstag auf das Ziel setzen. Hoechstens eine offene Aufgabe je Art und Abo
|
* "periodenende" = zum Faelligkeitstag auf das Ziel setzen. Hoechstens eine offene Aufgabe je Art und Abo
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Uebernahmegutschrift.
|
* Uebernahmegutschrift.
|
||||||
* Kunde: /api/abo/takeover-credits (Antrag stellen, Rueckfrage beantworten)
|
* Kunde: /api/abo/takeover-credits (Antrag stellen, Rueckfrage beantworten)
|
||||||
* Admin: /api/portal-admin/takeover-credits (Liste, Pruefung, Entscheidung, Nachweise)
|
* Team: /api/team/takeover-credits (Ticketsystem: Liste, Pruefung, Entscheidung, Nachweise)
|
||||||
* Der JSON-Body darf hier groesser sein (Nachweise als Base64), siehe server/index.js.
|
* Der JSON-Body darf hier groesser sein (Nachweise als Base64), siehe server/index.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann kein Antrag gestellt werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann kein Antrag gestellt werden.' })
|
||||||
}
|
}
|
||||||
const customerId = getSessionCustomerId(req)
|
const customerId = getSessionCustomerId(req)
|
||||||
if (rateLimited(customerId)) {
|
if (rateLimited(customerId)) {
|
||||||
@@ -92,7 +92,7 @@ router.post('/', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/:id/reply', async (req, res) => {
|
router.post('/:id/reply', async (req, res) => {
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
|
||||||
}
|
}
|
||||||
const customerId = getSessionCustomerId(req)
|
const customerId = getSessionCustomerId(req)
|
||||||
if (rateLimited(customerId)) {
|
if (rateLimited(customerId)) {
|
||||||
@@ -113,7 +113,7 @@ router.post('/:id/reply', async (req, res) => {
|
|||||||
export default router
|
export default router
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Admin (eingehaengt in routes/portalAdmin.js, dort hinter requireAdminSession)
|
// Team (eingehaengt in routes/team/index.js, dort hinter requireStaffJwt)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const takeoverAdminRouter = Router()
|
export const takeoverAdminRouter = Router()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ router.use(requireStaffJwt)
|
|||||||
router.use('/customers', customersRoutes)
|
router.use('/customers', customersRoutes)
|
||||||
router.use('/preview-link', previewRoutes)
|
router.use('/preview-link', previewRoutes)
|
||||||
router.use(chatAdminRouter)
|
router.use(chatAdminRouter)
|
||||||
// Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen: dieselben Router wie im Admin-Panel
|
// Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen (frueher im Admin-Panel des Portals)
|
||||||
router.use('/takeover-credits', takeoverAdminRouter)
|
router.use('/takeover-credits', takeoverAdminRouter)
|
||||||
router.use('/offers', offersAdminRouter)
|
router.use('/offers', offersAdminRouter)
|
||||||
router.use('/storage-tasks', storageTasksAdminRouter)
|
router.use('/storage-tasks', storageTasksAdminRouter)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { buildStaffPreviewLink } from '../admin/previewOpen.js'
|
import { buildStaffPreviewLink } from '../../services/staffPreviewLink.js'
|
||||||
|
|
||||||
// Preview aus dem Ticketsystem oeffnen (alle Mitarbeiter): Einmal-Link statt Mitarbeiter-Login im Portal
|
// Preview aus dem Ticketsystem oeffnen (alle Mitarbeiter): Einmal-Link statt Mitarbeiter-Login im Portal
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
/* Termine der Rechnungsanalyse.
|
|
||||||
Ein Termin ist kein eigener Datensatz, sondern haengt am Akquise-Ticket des
|
|
||||||
Kunden (workorders): startDate + startTime + endTime + assignedTo. Das WOMS-
|
|
||||||
Planboard liest genau diese Felder, damit Termin und Ticket nie auseinander
|
|
||||||
laufen. Achtung: WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als
|
|
||||||
"hhmm" - beides wird hier erzeugt und beim Lesen tolerant geparst. */
|
|
||||||
|
|
||||||
import { config } from '../config.js'
|
|
||||||
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
|
||||||
|
|
||||||
const TICKET_LIMIT = 500
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------- Formate --- */
|
|
||||||
// "2026-08-12" -> "12.08.2026"
|
|
||||||
export function isoToGerman(iso) {
|
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
|
|
||||||
if (!m) return ''
|
|
||||||
return `${m[3]}.${m[2]}.${m[1]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// "12.08.2026" und "12.8.2026" -> "2026-08-12"
|
|
||||||
export function germanToIso(value) {
|
|
||||||
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
|
|
||||||
if (!m) return ''
|
|
||||||
return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// "09:00" -> "0900"
|
|
||||||
export function timeToWoms(value) {
|
|
||||||
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
|
|
||||||
if (!m) return ''
|
|
||||||
return `${m[1].padStart(2, '0')}${m[2]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// "0900" -> "09:00"
|
|
||||||
export function womsToTime(value) {
|
|
||||||
const raw = String(value || '').trim()
|
|
||||||
if (!raw) return ''
|
|
||||||
const m = /^(\d{1,2}):?(\d{2})$/.exec(raw)
|
|
||||||
if (!m) return ''
|
|
||||||
return `${m[1].padStart(2, '0')}:${m[2]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHour(time) {
|
|
||||||
const m = /^(\d{2}):(\d{2})$/.exec(womsToTime(time))
|
|
||||||
if (!m) return ''
|
|
||||||
const hour = (Number(m[1]) + 1) % 24
|
|
||||||
return `${String(hour).padStart(2, '0')}${m[2]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------- Mitarbeiter --- */
|
|
||||||
export async function findEmployeeByEmail(email) {
|
|
||||||
const wanted = String(email || '').trim().toLowerCase()
|
|
||||||
if (!wanted) return null
|
|
||||||
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
|
|
||||||
return employees.find((e) => String(e.email || '').toLowerCase() === wanted) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------- Tickets --- */
|
|
||||||
function isClosed(ticket) {
|
|
||||||
return String(ticket.status || '').toLowerCase() === 'closed'
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Das Ticket, an das der Termin gehoert: bevorzugt das offene Akquise-Ticket.
|
|
||||||
Gibt es keins, kommt nur ein anderes offenes Ticket in Frage, das noch KEIN
|
|
||||||
Datum traegt - bei einem Projekt- oder Webpage-Ticket bedeutet startDate den
|
|
||||||
Projektstart, den wuerden wir sonst still ueberschreiben. */
|
|
||||||
export async function findAcquisitionTicket(customerId) {
|
|
||||||
const tickets = await listDocuments(config.collections.workorders, [
|
|
||||||
Query.equal('customerId', customerId),
|
|
||||||
Query.limit(100),
|
|
||||||
])
|
|
||||||
if (!tickets.length) return null
|
|
||||||
|
|
||||||
const newestFirst = [...tickets].sort((a, b) => String(b.$createdAt || '').localeCompare(String(a.$createdAt || '')))
|
|
||||||
const akquise = newestFirst.filter((t) => String(t.type || '').toLowerCase() === 'akquise')
|
|
||||||
if (akquise.length) return akquise.find((t) => !isClosed(t)) || akquise[0]
|
|
||||||
|
|
||||||
return newestFirst.find((t) => !isClosed(t) && !t.startDate) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAppointment(ticket, employeesByUserId) {
|
|
||||||
const dateIso = germanToIso(ticket.startDate)
|
|
||||||
const time = womsToTime(ticket.startTime)
|
|
||||||
if (!dateIso) return null
|
|
||||||
const employee = employeesByUserId[ticket.assignedTo] || null
|
|
||||||
return {
|
|
||||||
ticketId: ticket.$id,
|
|
||||||
woid: ticket.woid || '',
|
|
||||||
topic: ticket.topic || ticket.title || '',
|
|
||||||
type: ticket.type || '',
|
|
||||||
status: ticket.status || '',
|
|
||||||
customerId: ticket.customerId || '',
|
|
||||||
customerName: ticket.customerName || '',
|
|
||||||
customerLocation: ticket.customerLocation || '',
|
|
||||||
date: dateIso,
|
|
||||||
time,
|
|
||||||
endTime: womsToTime(ticket.endTime),
|
|
||||||
employeeUserId: ticket.assignedTo || '',
|
|
||||||
employeeName: employee?.displayName || ticket.assignedName || '',
|
|
||||||
employeeEmail: employee?.email || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alle terminierten Tickets in einem Zeitraum (ISO-Datum, inklusive). */
|
|
||||||
export async function listAppointments({ from = '', to = '' } = {}) {
|
|
||||||
const [tickets, employees] = await Promise.all([
|
|
||||||
listDocuments(config.collections.workorders, [Query.limit(TICKET_LIMIT)]),
|
|
||||||
listDocuments(config.collections.employees, [Query.limit(100)]),
|
|
||||||
])
|
|
||||||
|
|
||||||
const employeesByUserId = {}
|
|
||||||
for (const e of employees) employeesByUserId[e.userId] = e
|
|
||||||
|
|
||||||
return tickets
|
|
||||||
.map((t) => toAppointment(t, employeesByUserId))
|
|
||||||
.filter((a) => a && (!from || a.date >= from) && (!to || a.date <= to))
|
|
||||||
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time))
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Termin am Akquise-Ticket eintragen. */
|
|
||||||
export async function saveAppointment({ customerId, employeeEmail, date, time }) {
|
|
||||||
const germanDate = isoToGerman(date)
|
|
||||||
const womsTime = timeToWoms(time)
|
|
||||||
if (!germanDate) throw badRequest('Ungültiges Datum')
|
|
||||||
if (!womsTime) throw badRequest('Ungültige Uhrzeit')
|
|
||||||
|
|
||||||
const employee = await findEmployeeByEmail(employeeEmail)
|
|
||||||
if (!employee) throw badRequest(`Kein Mitarbeiter mit der E-Mail ${employeeEmail} in WOMS gefunden`)
|
|
||||||
|
|
||||||
const ticket = await findAcquisitionTicket(customerId)
|
|
||||||
if (!ticket) {
|
|
||||||
throw badRequest('Kein passendes Ticket gefunden. Bitte in WOMS ein Akquise-Ticket für diesen Kunden anlegen.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await updateDocument(config.collections.workorders, ticket.$id, {
|
|
||||||
startDate: germanDate,
|
|
||||||
startTime: womsTime,
|
|
||||||
endTime: addHour(womsTime),
|
|
||||||
assignedTo: employee.userId,
|
|
||||||
assignedName: employee.displayName || '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
ticketId: updated.$id,
|
|
||||||
woid: updated.woid || '',
|
|
||||||
topic: updated.topic || '',
|
|
||||||
type: updated.type || '',
|
|
||||||
date,
|
|
||||||
time: womsToTime(womsTime),
|
|
||||||
employeeName: employee.displayName || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function badRequest(message) {
|
|
||||||
const error = new Error(message)
|
|
||||||
error.status = 400
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
|
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
|
||||||
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
|
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
|
||||||
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
|
* endet nach 2 Stunden. Sitzungen ohne viewId ("Als Kunde ansehen" im frueheren Admin-Panel) gelten als beendet
|
||||||
|
* (kundenansichtGuard, /api/auth/me).
|
||||||
*/
|
*/
|
||||||
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
|
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
|
||||||
const COOKIE_GRACE_MS = 60 * 60 * 1000
|
const COOKIE_GRACE_MS = 60 * 60 * 1000
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const lastCustomerNoticeByCustomer = new Map() // customerId -> timestamp
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC.
|
* Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC.
|
||||||
* Wirft nie; das Ergebnis zeigt das Admin-Panel unter der Nachricht an.
|
* Wirft nie; das Ergebnis zeigt das Ticketsystem unter der Nachricht an.
|
||||||
* @returns {Promise<{ sent: boolean, reason?: string, to?: string }>}
|
* @returns {Promise<{ sent: boolean, reason?: string, to?: string }>}
|
||||||
*/
|
*/
|
||||||
export async function sendCustomerChatNotice(customer, { sender, text }) {
|
export async function sendCustomerChatNotice(customer, { sender, text }) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { shortRef } from './takeoverCredits.js'
|
|||||||
* Individuelle Abo-Angebote.
|
* Individuelle Abo-Angebote.
|
||||||
*
|
*
|
||||||
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
|
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
|
||||||
* je 25 € statt 50 €) und legt ihn vorab im Admin-Panel an. Jedes Angebot bekommt sofort einen
|
* je 25 € statt 50 €) und legt ihn vorab im Ticketsystem an. Jedes Angebot bekommt sofort einen
|
||||||
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
|
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
|
||||||
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
|
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
|
||||||
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
|
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
|
||||||
@@ -24,7 +24,7 @@ const COLLECTION = () => config.collections.aboOffers
|
|||||||
|
|
||||||
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
|
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
|
||||||
|
|
||||||
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
|
// Anzeige im Team-Hinweis (wie im Ticketsystem)
|
||||||
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
|
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
|
||||||
|
|
||||||
function httpError(status, message) {
|
function httpError(status, message) {
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ async function currentPasswordVersion(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
|
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Sessions der
|
||||||
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
|
* Kundenansicht (Mitarbeiter) und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
|
||||||
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
|
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
|
||||||
*/
|
*/
|
||||||
export async function isSessionPasswordCurrent(session) {
|
export async function isSessionPasswordCurrent(session) {
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { config } from '../config.js'
|
||||||
import { config } from '../../config.js'
|
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
import { isPreviewProjectReady } from './previewReadiness.js'
|
||||||
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
|
import { createStaffPreviewToken } from './staffPreviewTokens.js'
|
||||||
import { isPreviewProjectReady } from '../../services/previewReadiness.js'
|
|
||||||
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
function isStaffAdmin(user) {
|
|
||||||
return (user?.labels || []).includes('admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
function safePreviewUrl(url) {
|
function safePreviewUrl(url) {
|
||||||
try {
|
try {
|
||||||
@@ -36,8 +28,8 @@ function previewUrlForSubdomain(subdomain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview). Genutzt von dieser
|
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview), genutzt vom
|
||||||
* Admin-Route und vom Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
|
* Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
|
||||||
*/
|
*/
|
||||||
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
|
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
|
||||||
const subdomain = String(rawSubdomain || '')
|
const subdomain = String(rawSubdomain || '')
|
||||||
@@ -76,22 +68,3 @@ export async function buildStaffPreviewLink({ userId, email, name, subdomain: ra
|
|||||||
return { status: err.status || 500, body: { error: err.message || 'Preview-Login fehlgeschlagen' } }
|
return { status: err.status || 500, body: { error: err.message || 'Preview-Login fehlgeschlagen' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router.use(requireAppwriteStaff)
|
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
|
||||||
const user = req.appwriteUser
|
|
||||||
if (!isStaffAdmin(user)) {
|
|
||||||
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
|
|
||||||
}
|
|
||||||
const { status, body } = await buildStaffPreviewLink({
|
|
||||||
userId: user.$id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
subdomain: req.body?.subdomain,
|
|
||||||
previewUrl: req.body?.previewUrl,
|
|
||||||
})
|
|
||||||
return res.status(status).json(body)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -21,9 +21,8 @@ export function createStaffPreviewToken(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
|
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) von der Kundenansicht aus dem
|
||||||
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
|
* Ticketsystem ('kundenansicht'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
|
||||||
* Zweck ist trotzdem verbraucht.
|
|
||||||
*/
|
*/
|
||||||
export function consumeStaffPreviewToken(token, purpose = 'preview') {
|
export function consumeStaffPreviewToken(token, purpose = 'preview') {
|
||||||
purgeExpired()
|
purgeExpired()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|||||||
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
|
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
|
||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
|
const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
|
||||||
const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, viewAsCookie, seedFixtures, subscriptionDoc, stripeSubscription } =
|
const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, stripeSubscription } =
|
||||||
await import('./support/fixtures.js')
|
await import('./support/fixtures.js')
|
||||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||||
const { recordContractEvent, listContractEvents, eventIdFor } = await import('../server/services/contractEvents.js')
|
const { recordContractEvent, listContractEvents, eventIdFor } = await import('../server/services/contractEvents.js')
|
||||||
@@ -123,7 +123,7 @@ test('GET /api/abo: Altbestand ohne productKey gilt als Wartungs-Abo', async ()
|
|||||||
assert.equal(r.subscription.canSwitch, false)
|
assert.equal(r.subscription.canSwitch, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => {
|
test('Ohne Session 401, alte Mitarbeiter-Sitzung ist keine Kunden-Session', async () => {
|
||||||
assert.equal((await rawGet('/api/abo')).status, 401)
|
assert.equal((await rawGet('/api/abo')).status, 401)
|
||||||
assert.equal((await rawGet('/api/abo', { cookie: adminCookie() })).status, 401)
|
assert.equal((await rawGet('/api/abo', { cookie: adminCookie() })).status, 401)
|
||||||
await assert.rejects(get('/api/abo'), (e) => e.status === 401)
|
await assert.rejects(get('/api/abo'), (e) => e.status === 401)
|
||||||
@@ -225,8 +225,8 @@ test('Checkout mit laufendem Abo → 409', async () => {
|
|||||||
assert.equal(call, null)
|
assert.equal(call, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Checkout im Modus „Als Kunde ansehen“ → 403', async () => {
|
test('Checkout in der Kundenansicht → 403', async () => {
|
||||||
const { res, call } = await checkout(orderBody('hosting'), { cookie: viewAsCookie('c3') })
|
const { res, call } = await checkout(orderBody('hosting'), { cookie: kundenansichtCookie({ customerId: 'c3' }) })
|
||||||
assert.equal(res.status, 403)
|
assert.equal(res.status, 403)
|
||||||
assert.equal(call, null)
|
assert.equal(call, null)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { test, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
|
||||||
|
// Etappe 4: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen sind weg; was das Ticketsystem nutzt, bleibt
|
||||||
|
const aw = await startAppwriteMock()
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
const { startTestApp } = await import('./support/testApp.js')
|
||||||
|
const { viewAsCookie } = await import('./support/fixtures.js')
|
||||||
|
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||||
|
|
||||||
|
// Hinter dem Waechter: eine Lese- und eine Schreib-Route, die selbst immer 200 liefern
|
||||||
|
const probe = Router()
|
||||||
|
probe.get('/', (_req, res) => res.json({ ok: true }))
|
||||||
|
probe.post('/', (_req, res) => res.json({ ok: true }))
|
||||||
|
const app = await startTestApp({ '/': kundenansichtGuard, '/api/projects': probe })
|
||||||
|
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||||
|
|
||||||
|
const root = new URL('..', import.meta.url).pathname
|
||||||
|
const clears = (res, name) => res.headers.getSetCookie().some((c) => c.startsWith(`${name}=`) && /Expires=Thu, 01 Jan 1970/.test(c))
|
||||||
|
|
||||||
|
function filesUnder(dir) {
|
||||||
|
const out = []
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const full = path.join(dir, name)
|
||||||
|
if (statSync(full).isDirectory()) out.push(...filesUnder(full))
|
||||||
|
else if (/\.(js|mjs|html|css)$/.test(name)) out.push(full)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Admin-Panel und alte Admin-Schnittstellen sind weg', () => {
|
||||||
|
for (const f of [
|
||||||
|
'public/admin.html', 'public/admin.js', 'public/kalkulation.html', 'public/kalkulation.js', 'public/kalkulation.css',
|
||||||
|
'server/routes/portalAdmin.js', 'server/routes/admin/switchLink.js', 'server/routes/admin/previewOpen.js', 'server/services/appointments.js',
|
||||||
|
]) {
|
||||||
|
assert.equal(existsSync(path.join(root, f)), false, f)
|
||||||
|
}
|
||||||
|
const index = readFileSync(path.join(root, 'server/index.js'), 'utf8')
|
||||||
|
for (const mount of ['/api/portal-admin', '/api/admin/switch-link', '/api/admin/preview-open']) assert.ok(!index.includes(`'${mount}'`), mount)
|
||||||
|
for (const f of ['public/chat-composer.js', 'public/chat-composer.css', 'public/kundenansicht.js', 'server/services/staffPreviewLink.js']) {
|
||||||
|
assert.ok(existsSync(path.join(root, f)), `${f} muss bleiben`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keine Verweise mehr auf admin.html, kalkulation.html oder /api/portal-admin in server/ und public/', () => {
|
||||||
|
// Mail-Vorlagen und ihre Links stellt Task 3 um (eigener Test in mailTemplates.test.js)
|
||||||
|
const skip = [path.join(root, 'server/mail-templates/'), path.join(root, 'server/services/takeoverCredits.js')]
|
||||||
|
const hits = []
|
||||||
|
for (const file of [...filesUnder(path.join(root, 'server')), ...filesUnder(path.join(root, 'public'))]) {
|
||||||
|
if (skip.some((s) => file.startsWith(s))) continue
|
||||||
|
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
|
||||||
|
if (/admin\.html|kalkulation\.html|\/api\/portal-admin/.test(line)) hits.push(`${path.relative(root, file)}:${i + 1}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
assert.deepEqual(hits, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Alt-Ansicht ohne Kennung: Lesen und Schreiben 401 view_expired, Cookies geloescht', async () => {
|
||||||
|
for (const method of ['GET', 'POST']) {
|
||||||
|
const res = await fetch(`${app.url}/api/projects`, {
|
||||||
|
method,
|
||||||
|
headers: { cookie: viewAsCookie('c1'), ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) },
|
||||||
|
body: method === 'POST' ? '{}' : undefined,
|
||||||
|
})
|
||||||
|
assert.equal(res.status, 401, method)
|
||||||
|
assert.equal((await res.json()).code, 'view_expired', method)
|
||||||
|
assert.ok(clears(res, config.cookieName), `${method}: Portal-Cookie geloescht`)
|
||||||
|
assert.ok(clears(res, 'preview_session'), `${method}: Preview-Cookie geloescht`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -49,7 +49,7 @@ test('Sessions: nach Passwortaenderung ungueltig, alte ohne Stand und Admins ble
|
|||||||
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1', pwv: '2026-09-01T08:00:00.000+00:00' }), false)
|
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1', pwv: '2026-09-01T08:00:00.000+00:00' }), false)
|
||||||
// Session von vor dieser Pruefung (ohne pwv) laeuft normal aus
|
// Session von vor dieser Pruefung (ohne pwv) laeuft normal aus
|
||||||
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1' }), true)
|
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1' }), true)
|
||||||
// "Als Kunde ansehen": appwriteUserId ist die des Admins
|
// Kundenansicht: appwriteUserId ist die des Mitarbeiters
|
||||||
assert.equal(await isSessionPasswordCurrent({ role: 'admin', viewAs: true, appwriteUserId: 'u1', customerId: 'c1', pwv: 'x' }), true)
|
assert.equal(await isSessionPasswordCurrent({ role: 'admin', viewAs: true, appwriteUserId: 'u1', customerId: 'c1', pwv: 'x' }), true)
|
||||||
// Konto ohne Passwort (geleert): keine Session gilt mehr
|
// Konto ohne Passwort (geleert): keine Session gilt mehr
|
||||||
rememberPasswordVersion('u2', '')
|
rememberPasswordVersion('u2', '')
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ const st = await startFakeStripe()
|
|||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
const { startTestApp, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
|
const { startTestApp, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
|
||||||
const { adminCookie, customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
||||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||||
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
|
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||||
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
|
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
|
||||||
const sf = await import('../server/services/siteFeatures.js')
|
const sf = await import('../server/services/siteFeatures.js')
|
||||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
@@ -27,7 +27,7 @@ const { mailSink } = await import('./support/mailSink.js')
|
|||||||
const app = await startTestApp({
|
const app = await startTestApp({
|
||||||
'/api/funktionen': funktionenRoutes,
|
'/api/funktionen': funktionenRoutes,
|
||||||
'/api/abo': aboRoutes,
|
'/api/abo': aboRoutes,
|
||||||
'/api/portal-admin': portalAdminRoutes,
|
'/api/team': teamRoutes,
|
||||||
'/api/public/news': publicNewsRoutes,
|
'/api/public/news': publicNewsRoutes,
|
||||||
})
|
})
|
||||||
const mails = mailSink()
|
const mails = mailSink()
|
||||||
@@ -235,9 +235,11 @@ test('parallele_aktivierung_mailt_einmal', async () => {
|
|||||||
assert.equal(mails.sent.slice(mailsBefore).filter((m) => m.subject === 'Deine News-Pinnwand ist bereit').length, 1)
|
assert.equal(mails.sent.slice(mailsBefore).filter((m) => m.subject === 'Deine News-Pinnwand ist bereit').length, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Admin ---
|
// ------------------------------------------------- Ticketsystem (Team) ---
|
||||||
const adminC = adminCookie()
|
// Mitarbeiter ohne Admin-Label ueber den Team-Eingang (JWT)
|
||||||
const adminPost = (customerId, body, cookie = adminC) => post(`/api/portal-admin/funktionen/${customerId}/news`, body, { cookie })
|
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||||
|
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||||||
|
const adminPost = (customerId, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/news`, body, opts)
|
||||||
|
|
||||||
test('admin_aktivieren_ohne_zahlung', async () => {
|
test('admin_aktivieren_ohne_zahlung', async () => {
|
||||||
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Admin-Test' })
|
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Admin-Test' })
|
||||||
@@ -247,12 +249,12 @@ test('admin_aktivieren_ohne_zahlung', async () => {
|
|||||||
const view = res.body.projects.find((p) => p.projectId === 'pa1').features.news
|
const view = res.body.projects.find((p) => p.projectId === 'pa1').features.news
|
||||||
assert.equal(view.state, 'active')
|
assert.equal(view.state, 'active')
|
||||||
assert.equal(view.ticketWoid, null)
|
assert.equal(view.ticketWoid, null)
|
||||||
assert.match(view.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Kenso: activate – Kulanz$/)
|
assert.match(view.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Kulanz$/)
|
||||||
const doc = await sf.getFeatureDoc('c1', 'pa1', 'news')
|
const doc = await sf.getFeatureDoc('c1', 'pa1', 'news')
|
||||||
assert.equal(doc.enabled, true)
|
assert.equal(doc.enabled, true)
|
||||||
assert.equal(doc.unlockedByPurchase, false)
|
assert.equal(doc.unlockedByPurchase, false)
|
||||||
assert.equal(workorders().length, wosBefore)
|
assert.equal(workorders().length, wosBefore)
|
||||||
const list = await get('/api/portal-admin/funktionen/c1', { cookie: adminC })
|
const list = await get('/api/team/funktionen/c1', STAFF)
|
||||||
assert.equal(list.projects.find((p) => p.projectId === 'pa1').features.news.state, 'active')
|
assert.equal(list.projects.find((p) => p.projectId === 'pa1').features.news.state, 'active')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,7 +266,7 @@ test('admin_abschalten', async () => {
|
|||||||
assert.equal(res.status, 200)
|
assert.equal(res.status, 200)
|
||||||
const view = res.body.projects[0].features.news
|
const view = res.body.projects[0].features.news
|
||||||
assert.equal(view.state, 'disabled')
|
assert.equal(view.state, 'disabled')
|
||||||
assert.match(view.adminNote, /Kenso: disable \| .*Kenso: activate – Kulanz$/)
|
assert.match(view.adminNote, /Andrej: disable \| .*Andrej: activate – Kulanz$/)
|
||||||
assert.ok(view.adminNote.length <= 300)
|
assert.ok(view.adminNote.length <= 300)
|
||||||
assert.equal((await sf.getFeatureDoc('c1', 'pa1', 'news')).enabled, false)
|
assert.equal((await sf.getFeatureDoc('c1', 'pa1', 'news')).enabled, false)
|
||||||
const { status, body } = await rawGet(publicUrl)
|
const { status, body } = await rawGet(publicUrl)
|
||||||
@@ -286,15 +288,15 @@ test('admin_notiz_bleibt_im_limit', async () => {
|
|||||||
}
|
}
|
||||||
const note = (await sf.getFeatureDoc('c1', 'pa2', 'news')).adminNote
|
const note = (await sf.getFeatureDoc('c1', 'pa2', 'news')).adminNote
|
||||||
assert.ok(note.length <= 300)
|
assert.ok(note.length <= 300)
|
||||||
assert.match(note, /^\S+ Kenso: disable – x+/)
|
assert.match(note, /^\S+ Andrej: disable – x+/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('kunde_darf_admin_route_nicht', async () => {
|
test('kunde_darf_admin_route_nicht', async () => {
|
||||||
aw.seed('websiteProjects', { $id: 'pa5', customerId: 'c1', projectName: 'Kundenversuch' })
|
aw.seed('websiteProjects', { $id: 'pa5', customerId: 'c1', projectName: 'Kundenversuch' })
|
||||||
const res = await adminPost('c1', { projectId: 'pa5', action: 'activate' }, customerCookie('c1'))
|
const res = await adminPost('c1', { projectId: 'pa5', action: 'activate' }, { cookie: customerCookie('c1') })
|
||||||
assert.ok(res.status === 401 || res.status === 403)
|
assert.ok(res.status === 401 || res.status === 403)
|
||||||
assert.equal(await sf.getFeatureDoc('c1', 'pa5', 'news'), null)
|
assert.equal(await sf.getFeatureDoc('c1', 'pa5', 'news'), null)
|
||||||
const list = await rawGet('/api/portal-admin/funktionen/c1', { cookie: customerCookie('c1') })
|
const list = await rawGet('/api/team/funktionen/c1', { cookie: customerCookie('c1') })
|
||||||
assert.ok(list.status === 401 || list.status === 403)
|
assert.ok(list.status === 401 || list.status === 403)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,7 +307,7 @@ test('ticket_fehlt_markiert', async () => {
|
|||||||
const doc = await sf.getFeatureDoc('c1', 'pa3', 'news')
|
const doc = await sf.getFeatureDoc('c1', 'pa3', 'news')
|
||||||
assert.equal(doc.state, 'setup')
|
assert.equal(doc.state, 'setup')
|
||||||
const newsOf = async () =>
|
const newsOf = async () =>
|
||||||
(await get('/api/portal-admin/funktionen/c1', { cookie: adminC })).projects.find((p) => p.projectId === 'pa3').features.news
|
(await get('/api/team/funktionen/c1', STAFF)).projects.find((p) => p.projectId === 'pa3').features.news
|
||||||
assert.equal((await newsOf()).ticketMissing, false)
|
assert.equal((await newsOf()).ticketMissing, false)
|
||||||
await deleteDocument(config.collections.workorders, doc.ticketId)
|
await deleteDocument(config.collections.workorders, doc.ticketId)
|
||||||
const missing = await newsOf()
|
const missing = await newsOf()
|
||||||
|
|||||||
@@ -156,13 +156,16 @@ test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abge
|
|||||||
assert.deepEqual([expired.at(-1).customerId, expired.at(-1).staffName], ['c1', 'Andrej'])
|
assert.deepEqual([expired.at(-1).customerId, expired.at(-1).staffName], ['c1', 'Andrej'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => {
|
test('alte Ansicht ohne viewId (frueheres Admin-Panel): Lesen, Schreiben, Logout 401 view_expired, Cookies weg, kein Protokoll', async () => {
|
||||||
const cookie = viewAsCookie('c1')
|
const cookie = viewAsCookie('c1')
|
||||||
assert.equal((await call('GET', '/api/news?projectId=p1', { cookie })).status, 200)
|
const before = logEntries().length
|
||||||
const write = await call('POST', '/api/news', { cookie, body: note })
|
for (const [method, p, body] of [['GET', '/api/news?projectId=p1'], ['POST', '/api/news', note], ['POST', '/api/auth/logout']]) {
|
||||||
assert.equal(write.status, 403)
|
const res = await call(method, p, { cookie, body })
|
||||||
assert.equal(write.body.code, 'view_blocked')
|
assert.equal(res.status, 401, `${method} ${p}`)
|
||||||
assert.equal((await call('POST', '/api/auth/logout', { cookie })).status, 200)
|
assert.equal(res.body.code, 'view_expired', `${method} ${p}`)
|
||||||
|
assert.ok(clears(res, config.cookieName), `${method} ${p}: Portal-Cookie geloescht`)
|
||||||
|
}
|
||||||
|
assert.equal(logEntries().length, before)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {
|
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {
|
||||||
@@ -192,18 +195,18 @@ test('Grossschreibung umgeht den Waechter nicht: /API/... wie /api/...', async (
|
|||||||
assert.equal(res.status, 403, `${method} ${upper}`)
|
assert.equal(res.status, 403, `${method} ${upper}`)
|
||||||
}
|
}
|
||||||
const legacy = await call('POST', '/API/news', { cookie: viewAsCookie('c1'), body: note })
|
const legacy = await call('POST', '/API/news', { cookie: viewAsCookie('c1'), body: note })
|
||||||
assert.equal(legacy.status, 403)
|
assert.equal(legacy.status, 401)
|
||||||
const expired = await call('GET', '/Api/Projects', kv({ viewExpiresAt: Date.now() - 1000 }))
|
const expired = await call('GET', '/Api/Projects', kv({ viewExpiresAt: Date.now() - 1000 }))
|
||||||
assert.equal(expired.status, 401)
|
assert.equal(expired.status, 401)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten', async () => {
|
test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten', async () => {
|
||||||
const header = { 'x-kundenansicht': VIEW }
|
const header = { 'x-kundenansicht': VIEW }
|
||||||
for (const cookie of [customerCookie('c2'), viewAsCookie('c2')]) {
|
const res = await call('GET', '/api/projects', { cookie: customerCookie('c2'), headers: header })
|
||||||
const res = await call('GET', '/api/projects', { cookie, headers: header })
|
|
||||||
assert.equal(res.status, 409)
|
assert.equal(res.status, 409)
|
||||||
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
|
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
|
||||||
}
|
// alte Ansicht ohne Kennung: beendet, ebenfalls keine fremden Daten
|
||||||
|
assert.equal((await call('GET', '/api/projects', { cookie: viewAsCookie('c2'), headers: header })).status, 401)
|
||||||
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
|
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
|
||||||
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
|
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
|
||||||
})
|
})
|
||||||
@@ -18,7 +18,7 @@ const st = await startFakeStripe()
|
|||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
const { startTestApp, post, rawGet, postWebhook } = await import('./support/testApp.js')
|
const { startTestApp, post, rawGet, postWebhook } = await import('./support/testApp.js')
|
||||||
const { hostingDoc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
|
const { hostingDoc, consent, customerCookie, kundenansichtCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
|
||||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||||
const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage, storageTaskKind } =
|
const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage, storageTaskKind } =
|
||||||
await import('../server/services/mailStorage.js')
|
await import('../server/services/mailStorage.js')
|
||||||
@@ -1024,13 +1024,13 @@ test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde
|
|||||||
// Routen
|
// Routen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
test('Routen: ohne Session 401, im Modus „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => {
|
test('Routen: ohne Session 401, in der Kundenansicht 403, ohne Stripe-Aufruf', async () => {
|
||||||
await resetSubH({ units: 0 })
|
await resetSubH({ units: 0 })
|
||||||
const before = st.calls.length
|
const before = st.calls.length
|
||||||
assert.equal((await post('/api/abo/storage/preview', { units: 2 })).status, 401)
|
assert.equal((await post('/api/abo/storage/preview', { units: 2 })).status, 401)
|
||||||
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion })).status, 401)
|
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion })).status, 401)
|
||||||
assert.equal((await post('/api/abo/storage/preview', { units: 2 }, { cookie: viewAsCookie() })).status, 403)
|
assert.equal((await post('/api/abo/storage/preview', { units: 2 }, { cookie: kundenansichtCookie() })).status, 403)
|
||||||
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie: viewAsCookie() })).status, 403)
|
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie: kundenansichtCookie() })).status, 403)
|
||||||
assert.equal(st.calls.length, before)
|
assert.equal(st.calls.length, before)
|
||||||
assert.equal((await rawGet('/api/abo/storage')).status, 404)
|
assert.equal((await rawGet('/api/abo/storage')).status, 404)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
|||||||
const { default: newsRoutes } = await import('../server/routes/news.js')
|
const { default: newsRoutes } = await import('../server/routes/news.js')
|
||||||
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
|
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
|
||||||
|
|
||||||
// Waechter wie in server/index.js: er sperrt Schreibzugriffe der alten Ansicht "Als Kunde ansehen"
|
// Waechter wie in server/index.js: alte Ansichten ohne Kennung (frueheres Admin-Panel) gelten als beendet
|
||||||
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
|
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
|
||||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||||
@@ -55,10 +55,12 @@ test('nicht_aktiv_409', async () => {
|
|||||||
assert.equal(res.body.error, 'News ist für diese Website nicht aktiv.')
|
assert.equal(res.body.error, 'News ist für diese Website nicht aktiv.')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('fremdes_projekt_403_und_viewAs_403', async () => {
|
test('fremdes_projekt_403_und_alte_Ansicht_401', async () => {
|
||||||
assert.equal((await post('/api/news', { ...note, projectId: 'p3' }, { cookie })).status, 403)
|
assert.equal((await post('/api/news', { ...note, projectId: 'p3' }, { cookie })).status, 403)
|
||||||
const admin = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
|
const alt = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
|
||||||
assert.equal((await post('/api/news', note, { cookie: admin })).status, 403)
|
const res = await post('/api/news', note, { cookie: alt })
|
||||||
|
assert.equal(res.status, 401)
|
||||||
|
assert.equal(res.body.code, 'view_expired')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('kunde_crud', async () => {
|
test('kunde_crud', async () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|||||||
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
|
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
|
||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js')
|
const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js')
|
||||||
const { hostingDoc, hosting2Doc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription, subscriptionDoc } =
|
const { hostingDoc, hosting2Doc, consent, customerCookie, kundenansichtCookie, seedFixtures, stripeSubscription, subscriptionDoc } =
|
||||||
await import('./support/fixtures.js')
|
await import('./support/fixtures.js')
|
||||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||||
const { previewSwitch, switchToWartung, markSwitchFailed, previewProblem, paymentMethodTypeOf, reconcilePendingSwitch, SWITCH_FAILED_REASONS } =
|
const { previewSwitch, switchToWartung, markSwitchFailed, previewProblem, paymentMethodTypeOf, reconcilePendingSwitch, SWITCH_FAILED_REASONS } =
|
||||||
@@ -590,7 +590,7 @@ test('Route: ohne earlyStart 400', async () => {
|
|||||||
assert.equal(res.status, 400)
|
assert.equal(res.status, 400)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => {
|
test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, Kundenansicht 403, ohne Stripe-Aufruf', async () => {
|
||||||
await resetHosting()
|
await resetHosting()
|
||||||
const cookie = customerCookie()
|
const cookie = customerCookie()
|
||||||
const body = { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }
|
const body = { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }
|
||||||
@@ -607,8 +607,8 @@ test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, „Als K
|
|||||||
assert.match(stale.body.error, /Bedingungen/)
|
assert.match(stale.body.error, /Bedingungen/)
|
||||||
assert.equal((await post('/api/abo/switch', body)).status, 401)
|
assert.equal((await post('/api/abo/switch', body)).status, 401)
|
||||||
assert.equal((await post('/api/abo/switch/preview', {})).status, 401)
|
assert.equal((await post('/api/abo/switch/preview', {})).status, 401)
|
||||||
assert.equal((await post('/api/abo/switch', body, { cookie: viewAsCookie() })).status, 403)
|
assert.equal((await post('/api/abo/switch', body, { cookie: kundenansichtCookie() })).status, 403)
|
||||||
assert.equal((await post('/api/abo/switch/preview', {}, { cookie: viewAsCookie() })).status, 403)
|
assert.equal((await post('/api/abo/switch/preview', {}, { cookie: kundenansichtCookie() })).status, 403)
|
||||||
assert.equal(st.calls.length, before)
|
assert.equal(st.calls.length, before)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { test, after } from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
||||||
import { startFakeStripe } from './support/fakeStripe.js'
|
|
||||||
import { useTestEnv } from './support/testEnv.js'
|
|
||||||
|
|
||||||
// Wechsel Ticketsystem -> Projekt-Admin per Einmal-Link (purpose 'switch'), Rueckweg-Button im Admin
|
|
||||||
const aw = await startAppwriteMock()
|
|
||||||
const st = await startFakeStripe()
|
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
||||||
const { config } = await import('../server/config.js')
|
|
||||||
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
|
|
||||||
const { default: authRoutes } = await import('../server/routes/auth.js')
|
|
||||||
const { default: switchLinkRoutes } = await import('../server/routes/admin/switchLink.js')
|
|
||||||
const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js')
|
|
||||||
const app = await startTestApp({ '/api/auth': authRoutes, '/api/admin/switch-link': switchLinkRoutes })
|
|
||||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
|
||||||
|
|
||||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
|
||||||
|
|
||||||
const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } })
|
|
||||||
const switchLink = (jwt) => post('/api/admin/switch-link', {}, jwt ? bearer(jwt) : {})
|
|
||||||
const tokenOf = (openUrl) => new URL(openUrl).searchParams.get('token')
|
|
||||||
const portalSession = (res) => {
|
|
||||||
const cookie = res.headers.getSetCookie().find((c) => c.startsWith(`${config.cookieName}=`))
|
|
||||||
if (!cookie) return null
|
|
||||||
const raw = decodeURIComponent(cookie.split(';')[0].slice(config.cookieName.length + 1))
|
|
||||||
return JSON.parse(raw.slice(2, raw.lastIndexOf('.')))
|
|
||||||
}
|
|
||||||
|
|
||||||
test('switch-link: ohne, ungueltiges und halbes JWT -> 401', async () => {
|
|
||||||
assert.equal((await switchLink(null)).status, 401)
|
|
||||||
assert.equal((await switchLink('jwt-falsch')).status, 401)
|
|
||||||
assert.equal((await switchLink('jwt-halb')).status, 401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('switch-link: kein Mitarbeiter -> 403', async () => {
|
|
||||||
assert.equal((await switchLink('jwt-kunde')).status, 403)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('switch-link: Mitarbeiter ohne Admin-Label bekommt einen Link', async () => {
|
|
||||||
const res = await switchLink('jwt-staff')
|
|
||||||
assert.equal(res.status, 200)
|
|
||||||
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-switch\?token=[0-9a-f]{64}$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('admin.html: Rueckweg ins Ticketsystem in der Kopfzeile', () => {
|
|
||||||
const html = readFileSync(new URL('../public/admin.html', import.meta.url), 'utf8')
|
|
||||||
const actions = html.slice(html.indexOf('portal-header-actions'), html.indexOf('id="logout-btn"'))
|
|
||||||
assert.match(actions, /<a class="portal-btn-outline" href="https:\/\/ticket\.webklar\.com\/tickets">← Ticketsystem<\/a>/)
|
|
||||||
})
|
|
||||||
+34
-39
@@ -7,8 +7,8 @@ import { schemaKeys } from './support/schema.js'
|
|||||||
import { mailSink } from './support/mailSink.js'
|
import { mailSink } from './support/mailSink.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task 13: Admin-Reiter "E-Mail-Speicher" (GET/POST /api/portal-admin/storage-tasks), Schnittstelle fuers
|
* "E-Mail-Speicher" im Ticketsystem (GET/POST /api/team/storage-tasks, alle Mitarbeiter), Schnittstelle fuers
|
||||||
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden des Reiters.
|
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden der Liste.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
|
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
|
||||||
@@ -21,16 +21,16 @@ const aw = await startAppwriteMock({
|
|||||||
const st = await startFakeStripe()
|
const st = await startFakeStripe()
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
const { startTestApp, get, post, rawGet, sessionCookie } = await import('./support/testApp.js')
|
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
|
||||||
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } =
|
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, seedFixtures, stripeSubscription } =
|
||||||
await import('./support/fixtures.js')
|
await import('./support/fixtures.js')
|
||||||
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
|
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||||
const { default: adminCustomersRoutes } = await import('../server/routes/admin/customers.js')
|
const { default: adminCustomersRoutes } = await import('../server/routes/admin/customers.js')
|
||||||
const { listContractEvents, recordContractEvent, getContractEvent } = await import('../server/services/contractEvents.js')
|
const { listContractEvents, recordContractEvent, getContractEvent } = await import('../server/services/contractEvents.js')
|
||||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||||
|
|
||||||
const app = await startTestApp({ '/api/portal-admin': portalAdminRoutes, '/api/admin/customers': adminCustomersRoutes })
|
const app = await startTestApp({ '/api/team': teamRoutes, '/api/admin/customers': adminCustomersRoutes })
|
||||||
// F25: Mails (K14 beim Wechsel-Abgleich) landen in der Senke statt bei SMTP
|
// F25: Mails (K14 beim Wechsel-Abgleich) landen in der Senke statt bei SMTP
|
||||||
const mails = mailSink()
|
const mails = mailSink()
|
||||||
setMailTransportForTests(mails)
|
setMailTransportForTests(mails)
|
||||||
@@ -46,7 +46,8 @@ st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage
|
|||||||
|
|
||||||
const HOUR = 3600000
|
const HOUR = 3600000
|
||||||
const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString()
|
const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString()
|
||||||
const ADMIN = { cookie: adminCookie() }
|
// Mitarbeiter ohne Admin-Label (Team-Eingang: alle Mitarbeiter gleich)
|
||||||
|
const ADMIN = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||||||
const STAFF_JWT = { headers: { Authorization: 'Bearer jwt-admin' } }
|
const STAFF_JWT = { headers: { Authorization: 'Bearer jwt-admin' } }
|
||||||
|
|
||||||
async function clearEvents() {
|
async function clearEvents() {
|
||||||
@@ -77,6 +78,7 @@ beforeEach(async () => {
|
|||||||
await clearEvents()
|
await clearEvents()
|
||||||
mails.sent.length = 0
|
mails.sent.length = 0
|
||||||
seedFixtures(aw)
|
seedFixtures(aw)
|
||||||
|
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||||
// sub_h wurde von 3 auf 1 Paket verringert; konsoleH haelt bis zum Periodenende noch 3 (offene Aufgabe unten)
|
// sub_h wurde von 3 auf 1 Paket verringert; konsoleH haelt bis zum Periodenende noch 3 (offene Aufgabe unten)
|
||||||
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd })
|
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd })
|
||||||
aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd })
|
aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd })
|
||||||
@@ -88,7 +90,7 @@ beforeEach(async () => {
|
|||||||
|
|
||||||
test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => {
|
test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => {
|
||||||
// sub_h: 1 Paket, aber 3→1 erst zum Periodenende → konsoleH haelt 5 + 15 = 20 GB; sub_h2: 5 + 10 = 15 GB
|
// sub_h: 1 Paket, aber 3→1 erst zum Periodenende → konsoleH haelt 5 + 15 = 20 GB; sub_h2: 5 + 10 = 15 GB
|
||||||
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const r = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.equal(r.allocatedGb, 35)
|
assert.equal(r.allocatedGb, 35)
|
||||||
assert.equal(r.packageGb, 50)
|
assert.equal(r.packageGb, 50)
|
||||||
assert.equal(r.warnGb, 40)
|
assert.equal(r.warnGb, 40)
|
||||||
@@ -116,7 +118,7 @@ test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)
|
|||||||
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...hostingDoc, customerId: 'c3', status: 'canceled', storageUnits: 10 }))
|
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...hostingDoc, customerId: 'c3', status: 'canceled', storageUnits: 10 }))
|
||||||
aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 }))
|
aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 }))
|
||||||
try {
|
try {
|
||||||
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const r = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.equal(r.allocatedGb, 45)
|
assert.equal(r.allocatedGb, 45)
|
||||||
} finally {
|
} finally {
|
||||||
await deleteDocument(config.collections.subscriptions, 'sub_alt')
|
await deleteDocument(config.collections.subscriptions, 'sub_alt')
|
||||||
@@ -125,31 +127,24 @@ test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Admin: belegter Speicher zaehlt die Verringerung erst nach Erledigt', async () => {
|
test('Admin: belegter Speicher zaehlt die Verringerung erst nach Erledigt', async () => {
|
||||||
const before = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const before = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.equal(before.allocatedGb, 35, 'offene Aufgabe 3→1: konsoleH haelt noch 20 GB fuer sub_h')
|
assert.equal(before.allocatedGb, 35, 'offene Aufgabe 3→1: konsoleH haelt noch 20 GB fuer sub_h')
|
||||||
const task = before.tasks.find((t) => t.kind === 'periodenende')
|
const task = before.tasks.find((t) => t.kind === 'periodenende')
|
||||||
await post(`/api/portal-admin/storage-tasks/${task.id}/done`, {}, ADMIN)
|
await post(`/api/team/storage-tasks/${task.id}/done`, {}, ADMIN)
|
||||||
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const after = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.equal(after.allocatedGb, 25, 'nach Erledigt: 5 + 5 = 10 GB fuer sub_h, dazu 15 GB fuer sub_h2')
|
assert.equal(after.allocatedGb, 25, 'nach Erledigt: 5 + 5 = 10 GB fuer sub_h, dazu 15 GB fuer sub_h2')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Admin: Aufgabe nennt Gesamtspeicher des Kunden und Kontakt-Domain', async () => {
|
test('Admin: Aufgabe nennt Gesamtspeicher des Kunden und Kontakt-Domain', async () => {
|
||||||
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.equal(tasks[0].targetGb, 15)
|
assert.equal(tasks[0].targetGb, 15)
|
||||||
assert.equal(tasks[0].contactDomain, 'example.invalid')
|
assert.equal(tasks[0].contactDomain, 'example.invalid')
|
||||||
assert.equal(tasks[1].targetGb, 10)
|
assert.equal(tasks[1].targetGb, 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Admin: Erledigt ohne E-Mail des Mitarbeiters wird abgelehnt (400)', async () => {
|
|
||||||
const noEmail = { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso' }) }
|
|
||||||
const r = await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, noEmail)
|
|
||||||
assert.equal(r.status, 400)
|
|
||||||
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Admin: Erledigt auf ein gescheitertes Ereignis → 409', async () => {
|
test('Admin: Erledigt auf ein gescheitertes Ereignis → 409', async () => {
|
||||||
await storageEvent({ id: 'speicher-gescheitert-x', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' })
|
await storageEvent({ id: 'speicher-gescheitert-x', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' })
|
||||||
const r = await post('/api/portal-admin/storage-tasks/speicher-gescheitert-x/done', {}, ADMIN)
|
const r = await post('/api/team/storage-tasks/speicher-gescheitert-x/done', {}, ADMIN)
|
||||||
assert.equal(r.status, 409)
|
assert.equal(r.status, 409)
|
||||||
assert.equal((await getContractEvent('speicher-gescheitert-x')).status, 'gescheitert')
|
assert.equal((await getContractEvent('speicher-gescheitert-x')).status, 'gescheitert')
|
||||||
})
|
})
|
||||||
@@ -159,41 +154,41 @@ test('Admin: gescheiterte Aenderungen nur der letzten 30 Tage', async () => {
|
|||||||
await storageEvent({ id: 'speicher-gescheitert-alt', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 5, dueAt: iso(-2000), status: 'gescheitert' })
|
await storageEvent({ id: 'speicher-gescheitert-alt', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 5, dueAt: iso(-2000), status: 'gescheitert' })
|
||||||
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-alt')), $createdAt: iso(-31 * 24 * HOUR) })
|
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-alt')), $createdAt: iso(-31 * 24 * HOUR) })
|
||||||
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-neu')), $createdAt: iso(-29 * 24 * HOUR) })
|
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-neu')), $createdAt: iso(-29 * 24 * HOUR) })
|
||||||
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const r = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.deepEqual(r.failed.map((f) => f.id), ['speicher-gescheitert-neu'])
|
assert.deepEqual(r.failed.map((f) => f.id), ['speicher-gescheitert-neu'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Admin: Aufgabe erledigen', async () => {
|
test('Admin: Aufgabe erledigen', async () => {
|
||||||
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
|
||||||
const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
|
const { status, body: { task } } = await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
|
||||||
assert.equal(status, 200)
|
assert.equal(status, 200)
|
||||||
assert.equal(task.status, 'erledigt')
|
assert.equal(task.status, 'erledigt')
|
||||||
assert.equal(task.doneBy, 'kenso@webklar.com')
|
assert.equal(task.doneBy, 'andrej@webklar.com')
|
||||||
assert.ok(task.doneAt)
|
assert.ok(task.doneAt)
|
||||||
assert.equal(task.id, tasks[0].id)
|
assert.equal(task.id, tasks[0].id)
|
||||||
const stored = await getContractEvent(tasks[0].id)
|
const stored = await getContractEvent(tasks[0].id)
|
||||||
assert.equal(stored.status, 'erledigt')
|
assert.equal(stored.status, 'erledigt')
|
||||||
assert.equal(stored.doneBy, 'kenso@webklar.com')
|
assert.equal(stored.doneBy, 'andrej@webklar.com')
|
||||||
// Die Liste zeigt sie nicht mehr
|
// Die Liste zeigt sie nicht mehr
|
||||||
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const after = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.deepEqual(after.tasks.map((t) => t.id), [tasks[1].id])
|
assert.deepEqual(after.tasks.map((t) => t.id), [tasks[1].id])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Admin: Erledigt nur fuer offene Speicher-Aufgaben (404 unbekannt, 409 schon erledigt)', async () => {
|
test('Admin: Erledigt nur fuer offene Speicher-Aufgaben (404 unbekannt, 409 schon erledigt)', async () => {
|
||||||
assert.equal((await post('/api/portal-admin/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
|
assert.equal((await post('/api/team/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
|
||||||
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
|
||||||
await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
|
await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
|
||||||
const again = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
|
const again = await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
|
||||||
assert.equal(again.status, 409)
|
assert.equal(again.status, 409)
|
||||||
// Ein Wechsel-Ereignis ist keine Speicher-Aufgabe
|
// Ein Wechsel-Ereignis ist keine Speicher-Aufgabe
|
||||||
await recordContractEvent({ id: 'wechsel-offen-x', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: {}, consent, status: 'offen' })
|
await recordContractEvent({ id: 'wechsel-offen-x', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: {}, consent, status: 'offen' })
|
||||||
assert.equal((await post('/api/portal-admin/storage-tasks/wechsel-offen-x/done', {}, ADMIN)).status, 404)
|
assert.equal((await post('/api/team/storage-tasks/wechsel-offen-x/done', {}, ADMIN)).status, 404)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Admin: teilweise (Stripe-Schritt 2 gescheitert) und gescheiterte Aenderungen sind sichtbar', async () => {
|
test('Admin: teilweise (Stripe-Schritt 2 gescheitert) und gescheiterte Aenderungen sind sichtbar', async () => {
|
||||||
await storageEvent({ id: 'speicher-teilweise-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 2, toUnits: 5, dueAt: iso(-1000), extra: { partial: true, error: 'Stripe: card_declined' } })
|
await storageEvent({ id: 'speicher-teilweise-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 2, toUnits: 5, dueAt: iso(-1000), extra: { partial: true, error: 'Stripe: card_declined' } })
|
||||||
await storageEvent({ id: 'speicher-gescheitert-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert', extra: { error: 'Stripe: resource_missing' } })
|
await storageEvent({ id: 'speicher-gescheitert-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert', extra: { error: 'Stripe: resource_missing' } })
|
||||||
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const r = await get('/api/team/storage-tasks', ADMIN)
|
||||||
const partial = r.tasks.find((t) => t.id === 'speicher-teilweise-c2')
|
const partial = r.tasks.find((t) => t.id === 'speicher-teilweise-c2')
|
||||||
assert.equal(partial.partial, true)
|
assert.equal(partial.partial, true)
|
||||||
assert.equal(partial.error, 'Stripe: card_declined')
|
assert.equal(partial.error, 'Stripe: card_declined')
|
||||||
@@ -218,7 +213,7 @@ test('Admin: Wechsel-Abgleich beim Laden des Reiters (verlorener Stripe-Webhook)
|
|||||||
consent,
|
consent,
|
||||||
status: 'offen',
|
status: 'offen',
|
||||||
})
|
})
|
||||||
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
|
const r = await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.equal(r.tasks.length, 2)
|
assert.equal(r.tasks.length, 2)
|
||||||
const ev = await getContractEvent('wechsel-abgelaufen-c1')
|
const ev = await getContractEvent('wechsel-abgelaufen-c1')
|
||||||
assert.equal(ev.status, 'gescheitert')
|
assert.equal(ev.status, 'gescheitert')
|
||||||
@@ -237,15 +232,15 @@ test('Admin: Wechsel-Abgleich laesst einen noch laufenden Wechsel in Ruhe', asyn
|
|||||||
status: 'offen',
|
status: 'offen',
|
||||||
})
|
})
|
||||||
const before = st.calls.length
|
const before = st.calls.length
|
||||||
await get('/api/portal-admin/storage-tasks', ADMIN)
|
await get('/api/team/storage-tasks', ADMIN)
|
||||||
assert.equal((await getContractEvent('wechsel-laufend-c1')).status, 'offen')
|
assert.equal((await getContractEvent('wechsel-laufend-c1')).status, 'offen')
|
||||||
assert.equal(st.calls.length, before, 'kein Stripe-Abruf fuer einen nicht abgelaufenen Wechsel')
|
assert.equal(st.calls.length, before, 'kein Stripe-Abruf fuer einen nicht abgelaufenen Wechsel')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Ohne Admin-Session 401', async () => {
|
test('Ohne Mitarbeiter-JWT 401 (auch mit Kunden-Cookie)', async () => {
|
||||||
assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401)
|
assert.equal((await rawGet('/api/team/storage-tasks', { cookie: customerCookie() })).status, 401)
|
||||||
assert.equal((await rawGet('/api/portal-admin/storage-tasks')).status, 401)
|
assert.equal((await rawGet('/api/team/storage-tasks')).status, 401)
|
||||||
assert.equal((await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401)
|
assert.equal((await post('/api/team/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401)
|
||||||
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
|
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -92,11 +92,11 @@ export const customerCookie = (customerId = 'c1') =>
|
|||||||
email: customers[customerId]?.email || '',
|
email: customers[customerId]?.email || '',
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */
|
/** Alte Mitarbeiter-Sitzung im Portal (Login bis Etappe 4): /api/auth/me beendet sie, Kunden-Routen 401. */
|
||||||
export const adminCookie = () =>
|
export const adminCookie = () =>
|
||||||
sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' })
|
sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' })
|
||||||
|
|
||||||
/** Admin im Modus "Als Kunde ansehen" (portalAdmin.js /view-as): Bestellen und Aendern sind gesperrt. */
|
/** Alte Ansicht "Als Kunde ansehen" aus dem frueheren Admin-Panel (ohne viewId): gilt als beendet (401 view_expired). */
|
||||||
export const viewAsCookie = (customerId = 'c1') =>
|
export const viewAsCookie = (customerId = 'c1') =>
|
||||||
sessionCookie({
|
sessionCookie({
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { sessionMiddleware } from '../../server/middleware/session.js'
|
|||||||
* Express-App für Routen-Tests: wie server/index.js mit sessionMiddleware() (cookieParser mit
|
* Express-App für Routen-Tests: wie server/index.js mit sessionMiddleware() (cookieParser mit
|
||||||
* SESSION_SECRET) und express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der
|
* SESSION_SECRET) und express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der
|
||||||
* Test übergibt:
|
* Test übergibt:
|
||||||
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes })
|
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/team': teamRoutes })
|
||||||
*
|
*
|
||||||
* Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module
|
* Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module
|
||||||
* dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die
|
* dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die
|
||||||
|
|||||||
@@ -8,17 +8,16 @@ import { startFakeStripe } from './support/fakeStripe.js'
|
|||||||
import { useTestEnv } from './support/testEnv.js'
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
import { mailSink } from './support/mailSink.js'
|
import { mailSink } from './support/mailSink.js'
|
||||||
|
|
||||||
// Chat ueber den Team-Eingang (/api/team, JWT) mit signierten Anhang-Links; Admin-Panel (/api/portal-admin) unveraendert
|
// Chat ueber den Team-Eingang (/api/team, JWT) mit signierten Anhang-Links
|
||||||
process.env.CHAT_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-chat-'))
|
process.env.CHAT_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-chat-'))
|
||||||
const aw = await startAppwriteMock()
|
const aw = await startAppwriteMock()
|
||||||
const st = await startFakeStripe()
|
const st = await startFakeStripe()
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
|
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
|
||||||
const { seedFixtures, adminCookie } = await import('./support/fixtures.js')
|
const { seedFixtures } = await import('./support/fixtures.js')
|
||||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||||
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
|
const app = await startTestApp({ '/api/team': teamRoutes })
|
||||||
const app = await startTestApp({ '/api/team': teamRoutes, '/api/portal-admin': portalAdminRoutes })
|
|
||||||
const sink = mailSink()
|
const sink = mailSink()
|
||||||
setMailTransportForTests(sink)
|
setMailTransportForTests(sink)
|
||||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||||
@@ -93,11 +92,6 @@ test('Lesen markiert Kundennachrichten', async () => {
|
|||||||
assert.equal((await rawGet('/api/team/chats/unread-count', staff)).body.unread, 0)
|
assert.equal((await rawGet('/api/team/chats/unread-count', staff)).body.unread, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Admin-Panel unveraendert: /api/portal-admin/chats mit Admin-Cookie 200, ohne 401', async () => {
|
|
||||||
assert.equal((await rawGet('/api/portal-admin/chats', { cookie: adminCookie() })).status, 200)
|
|
||||||
assert.equal((await rawGet('/api/portal-admin/chats')).status, 401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Team-Chat ohne JWT -> 401', async () => {
|
test('Team-Chat ohne JWT -> 401', async () => {
|
||||||
assert.equal((await rawGet('/api/team/chats')).status, 401)
|
assert.equal((await rawGet('/api/team/chats')).status, 401)
|
||||||
assert.equal((await post('/api/team/chats/c1/messages', { text: 'x' })).status, 401)
|
assert.equal((await post('/api/team/chats/c1/messages', { text: 'x' })).status, 401)
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
// Playwright-Check admin_funktionen: statischer Server + JSON-Fixtures, nicht Teil von npm test.
|
|
||||||
// Aufruf: node test/ui/admin-funktionen.ui.mjs
|
|
||||||
import http from 'node:http'
|
|
||||||
import { readFile } from 'node:fs/promises'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
|
||||||
|
|
||||||
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
|
||||||
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' }
|
|
||||||
const news = (state, extra = {}) => ({ state, siteKey: 'abcdefghijklmnop', ticketWoid: null, adminNote: '', ticketMissing: false, ...extra })
|
|
||||||
const projects = [
|
|
||||||
{ projectId: 'p1', projectName: 'Bäckerei', features: { news: news('off') } },
|
|
||||||
{ projectId: 'p2', projectName: 'Café', features: { news: news('setup', { ticketMissing: true }) } },
|
|
||||||
{ projectId: 'p3', projectName: 'Kiosk', features: { news: news('active', { ticketWoid: '1234' }) } },
|
|
||||||
]
|
|
||||||
const posts = []
|
|
||||||
|
|
||||||
const json = (res, status, body) => {
|
|
||||||
res.writeHead(status, { 'content-type': 'application/json' })
|
|
||||||
res.end(JSON.stringify(body))
|
|
||||||
}
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
const url = new URL(req.url, 'http://x')
|
|
||||||
if (url.pathname === '/api/auth/me') return json(res, 200, { authenticated: true, role: 'admin', admin: { name: 'Kenso', email: 'k@w.de' } })
|
|
||||||
if (url.pathname === '/api/portal-admin/customers') {
|
|
||||||
return json(res, 200, { customers: [{ id: 'c1', code: '1001', name: 'Test GmbH', email: 't@t.de', customerStatus: 'customer', portalAccessEnabled: true, projectCount: 3 }] })
|
|
||||||
}
|
|
||||||
if (url.pathname === '/api/portal-admin/funktionen/c1') return json(res, 200, { projects })
|
|
||||||
if (url.pathname === '/api/portal-admin/funktionen/c1/news' && req.method === 'POST') {
|
|
||||||
let raw = ''
|
|
||||||
for await (const chunk of req) raw += chunk
|
|
||||||
const body = JSON.parse(raw)
|
|
||||||
posts.push(body)
|
|
||||||
const p = projects.find((x) => x.projectId === body.projectId)
|
|
||||||
p.features.news = news(body.action === 'activate' ? 'active' : 'disabled', { adminNote: `2026-09-30T10:00:00.000Z Kenso: ${body.action}${body.note ? ` – ${body.note}` : ''}` })
|
|
||||||
return json(res, 200, { projects: [p] })
|
|
||||||
}
|
|
||||||
if (url.pathname.startsWith('/api/')) return json(res, 200, {})
|
|
||||||
try {
|
|
||||||
const file = path.join(pub, url.pathname === '/' ? 'admin.html' : url.pathname)
|
|
||||||
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
|
|
||||||
res.end(await readFile(file))
|
|
||||||
} catch {
|
|
||||||
res.writeHead(404)
|
|
||||||
res.end()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
||||||
|
|
||||||
let failed = 0
|
|
||||||
const check = (name, ok, detail = '') => {
|
|
||||||
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
|
|
||||||
if (!ok) failed += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch()
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage()
|
|
||||||
const dialogs = []
|
|
||||||
page.on('dialog', (d) => { dialogs.push(d.type()); d.dismiss() })
|
|
||||||
page.on('pageerror', (e) => check('keine Seitenfehler', false, e.message))
|
|
||||||
await page.goto(`http://127.0.0.1:${server.address().port}/admin.html`)
|
|
||||||
await page.click('.admin-tab[data-tab="customers"]')
|
|
||||||
await page.click('[data-features="c1"]')
|
|
||||||
await page.waitForSelector('[data-feature-project="p1"]')
|
|
||||||
const row = (id) => page.locator(`[data-feature-project="${id}"]`)
|
|
||||||
check('3 Zeilen', (await page.locator('[data-feature-project]').count()) === 3)
|
|
||||||
check('Ticket fehlt sichtbar', (await row('p2').innerText()).includes('Ticket fehlt'))
|
|
||||||
|
|
||||||
// Aktivieren: erst Rückfrage in der Zeile, nichts gesendet
|
|
||||||
await row('p1').locator('[data-feature-ask="activate"]').click()
|
|
||||||
check('Rückfrage statt Sofort-Aktion', posts.length === 0 && (await row('p1').innerText()).includes('ohne Zahlung aktivieren?'))
|
|
||||||
await row('p1').locator('[data-feature-no]').click()
|
|
||||||
check('Abbrechen stellt Knöpfe wieder her', (await row('p1').locator('[data-feature-ask="activate"]').count()) === 1 && posts.length === 0)
|
|
||||||
await row('p1').locator('[data-feature-ask="activate"]').click()
|
|
||||||
await row('p1').locator('[data-feature-note]').fill('Kulanz')
|
|
||||||
await row('p1').locator('[data-feature-yes]').click()
|
|
||||||
await page.waitForFunction(() => document.querySelector('[data-feature-project="p1"]')?.innerText.includes('Aktiv'))
|
|
||||||
check('POST activate mit Notiz', JSON.stringify(posts[0]) === JSON.stringify({ projectId: 'p1', action: 'activate', note: 'Kulanz' }), JSON.stringify(posts[0]))
|
|
||||||
check('Zeile zeigt Aktiv und Notiz', (await row('p1').innerText()).includes('Kenso: activate – Kulanz'))
|
|
||||||
|
|
||||||
// Abschalten
|
|
||||||
await row('p3').locator('[data-feature-ask="disable"]').click()
|
|
||||||
await row('p3').locator('[data-feature-yes]').click()
|
|
||||||
await page.waitForFunction(() => document.querySelector('[data-feature-project="p3"]')?.innerText.includes('Abgeschaltet'))
|
|
||||||
check('Abschalten aktualisiert Zeile', posts[1]?.action === 'disable')
|
|
||||||
check('keine Browser-Dialoge', dialogs.length === 0)
|
|
||||||
await page.screenshot({ path: '/tmp/claude-0/-root/a97f1ff7-2f71-45a7-bb9f-eaeacf7e64f1/scratchpad/admin-funktionen.png' })
|
|
||||||
} finally {
|
|
||||||
await browser.close()
|
|
||||||
server.close()
|
|
||||||
}
|
|
||||||
console.log(failed ? `admin_funktionen: ${failed} FEHLER` : 'admin_funktionen: alle Prüfungen bestanden')
|
|
||||||
process.exit(failed ? 1 : 0)
|
|
||||||
Reference in new issue
Block a user