Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 03:50:14 +00:00
1 parent 3a4fa581c0
commit 7c00633db0
43 files changed
+223 -3995

No files matched your search

-309
View File
@@ -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 &amp; 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
View File
File diff suppressed because it is too large. Load diff
+3 -15
View File
@@ -570,10 +570,9 @@ async function initLoginPage() {
/* --------------------------------------------------------------- View-as --- */
function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner')
if (!banner || !me.viewAs) return
if (!banner || !me.viewAs || !me.viewId) return
banner.classList.remove('hidden')
// Kundenansicht aus dem Ticketsystem (mit Kennung): Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
if (me.viewId) {
// Kundenansicht aus dem Ticketsystem: Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
banner.innerHTML = `
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
<button type="button" id="exit-viewas-btn">Beenden</button>
@@ -582,17 +581,6 @@ function renderViewAsBanner(me) {
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
window.location.href = redirect || 'https://ticket.webklar.com/customers'
})
return
}
// Alte Ansicht "Als Kunde ansehen" aus dem Admin-Panel (bis das Panel abgeschaltet ist)
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 --- */
@@ -1973,7 +1961,7 @@ function showView(name, { updateHash = true } = {}) {
}
/* Profilbild (DiceBear Big Smile vom eigenen Server); die Initialen bleiben stehen, bis das Bild geladen ist,
und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass "Als Kunde ansehen" nicht das gecachte Bild zeigt. */
und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass die Kundenansicht nicht das gecachte Bild zeigt. */
function setAvatar(el, initials, seed) {
if (!el) return
el.textContent = initials
+2 -2
View File
@@ -1,5 +1,5 @@
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Admin-Panel, siehe chat-composer.js).
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, admin.html setzt Tuerkis. */
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Ticketsystem, siehe chat-composer.js).
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, das Ticketsystem setzt eigene (styles/chat.css). */
:root {
--chat-accent: #9d3dfe;
--chat-accent-hover: #8a25f0;
+2 -2
View File
@@ -1,4 +1,4 @@
/* Chat im Kundenbereich und im Admin-Panel: Anhaenge (Bild, Kamera, Datei) und Sprachnachrichten.
/* Chat im Kundenbereich und im Ticketsystem (laedt diese Datei zur Laufzeit): Anhaenge (Bild, Kamera, Datei) und Sprachnachrichten.
ChatComposer.create({...}) baut die Eingabezeile, ChatComposer.renderMessage(...) eine Sprechblase.
Erlaubte Endungen und Grenzen prueft der Server noch einmal (server/services/chatAttachments.js). */
(function () {
@@ -468,7 +468,7 @@
/**
* Sprechblase: Anhaenge, Text, Absender/Zeit.
* fileUrl(att) liefert die Adresse des Anhangs (Kunde: /api/chat/..., Admin: /api/portal-admin/...).
* fileUrl(att) liefert die Adresse des Anhangs (Kunde: /api/chat/..., Ticketsystem: signierter Link unter /api/team/...).
*/
function renderMessage(msg, { mine, meta, fileUrl }) {
const atts = Array.isArray(msg.attachments) ? msg.attachments : []
-372
View File
@@ -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); }
}
-176
View File
@@ -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>
-838
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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
-6
View File
@@ -13,7 +13,6 @@ import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
import featuresRoutes from './routes/features.js'
import customerDataRoutes from './routes/customerData.js'
import portalAdminRoutes from './routes/portalAdmin.js'
import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.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 adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { analyseLegalRouter } from './services/analyseLegal.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import teamRoutes from './routes/team/index.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', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
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/employees', adminEmployeesRoutes)
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/finanzen', adminFinanzenRoutes)
app.use('/webhook', giteaWebhookRoutes)
+11 -15
View File
@@ -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):
* 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)
* 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.
* Alte Ansichten aus dem Admin-Panel (ohne viewId) duerfen nichts schreiben ausser Ab- und Anmelden.
*/
export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/news\/?$/],
@@ -19,13 +19,9 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/auth\/logout\/?$/],
['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)
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/portal-admin/']
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/']
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
@@ -41,20 +37,20 @@ export async function kundenansichtGuard(req, res, next) {
if (!session) return next()
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)
await logOnce(session, 'abgelaufen')
if (current) await logOnce(session, 'abgelaufen')
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
// Admin-Ansicht, Kunden-Login): keine fremden Daten in diesen Tab
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht,
// Kunden-Login): keine fremden Daten in diesen Tab
const header = req.get('X-Kundenansicht')
if (header && header !== session.viewId) return replaced(res, session)
if (!session.viewAs) 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 (list.some(([method, re]) => method === req.method && re.test(pathname))) return next()
if (KUNDENANSICHT_ALLOWED.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.' })
}
-10
View File
@@ -58,16 +58,6 @@ export async function requireSession(req, res, 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) {
return req.session?.customerId
}
+6 -6
View File
@@ -216,7 +216,7 @@ router.get('/', requireSession, async (req, res) => {
router.post('/checkout', requireSession, async (req, res) => {
try {
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 || {}
if (!PRODUCT_KEYS.includes(productKey)) {
@@ -280,7 +280,7 @@ router.post('/checkout', requireSession, async (req, res) => {
router.post('/portal', requireSession, async (req, res) => {
try {
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))
return res.json({ url })
@@ -297,7 +297,7 @@ router.post('/portal', requireSession, async (req, res) => {
router.post('/storage/preview', requireSession, async (req, res) => {
try {
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))
} catch (err) {
@@ -314,7 +314,7 @@ router.post('/storage/preview', requireSession, async (req, res) => {
router.post('/storage', requireSession, async (req, res) => {
try {
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 { units, acceptTerms, termsVersion } = req.body || {}
@@ -350,7 +350,7 @@ router.post('/storage', requireSession, async (req, res) => {
router.post('/switch/preview', requireSession, async (req, res) => {
try {
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)))
} catch (err) {
@@ -368,7 +368,7 @@ router.post('/switch/preview', requireSession, async (req, res) => {
router.post('/switch', requireSession, async (req, res) => {
try {
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 || {}
if (acceptTerms !== true) {
+2 -2
View File
@@ -5,8 +5,8 @@ import { withLocks } from '../../services/keyedLock.js'
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
/**
* Funktionen (News-Pinnwand) je Kundenprojekt im Admin-Panel.
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/funktionen, dort hinter requireAdminSession.
* Funktionen (News-Pinnwand) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt.
*/
export const funktionenAdminRouter = Router()
-37
View File
@@ -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
+2 -2
View File
@@ -16,8 +16,8 @@ import {
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
* Ticketsystems (/api/team, JWT); beide liefern req.session mit appwriteUserId und Namen des Mitarbeiters.
* Chat des Teams mit den Kunden. Eingehaengt im Team-Eingang des Ticketsystems (/api/team, JWT); requireStaffJwt
* liefert req.session mit appwriteUserId und Namen des Mitarbeiters.
*/
export const chatAdminRouter = Router()
const router = chatAdminRouter
+3 -3
View File
@@ -134,7 +134,7 @@ async function openOrderSession(doc) {
router.post('/news/checkout', requireSession, async (req, res) => {
try {
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 || {}
if (consent !== true) {
@@ -182,7 +182,7 @@ const analyseGuard = [requireSession, requireAnalyseEnabled]
router.post('/analyse/activate', analyseGuard, async (req, res) => {
try {
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 { doc } = await activateAnalyse({
@@ -202,7 +202,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
try {
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({
customerId: getSessionCustomerId(req),
+2 -2
View File
@@ -4,8 +4,8 @@ import { listDocuments, Query } from '../services/appwriteAdmin.js'
import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js'
/**
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/offers, dort hinter requireAdminSession.
* Individuelle Abo-Angebote im Ticketsystem (Kunden -> Abo-Angebote, Kunden-Reiter "Abo & Angebote").
* Eingehaengt in routes/team/index.js unter /api/team/offers, dort hinter requireStaffJwt.
*/
export const offersAdminRouter = Router()
-331
View File
@@ -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
+4 -4
View File
@@ -8,10 +8,10 @@ import { PRODUCT_KEYS, productLabel } from '../services/products.js'
import { ACTIVE_STATUSES } from '../services/stripeBilling.js'
/**
* Admin-Reiter "E-Mail-Speicher" (Task 13): offene Zusatzspeicher-Aufgaben fuers Team (Postfachgrenze in
* konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen der letzten
* Wochen. Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/storage-tasks, dort hinter
* requireAdminSession.
* "E-Mail-Speicher" im Ticketsystem (Kunden -> E-Mail-Speicher): offene Zusatzspeicher-Aufgaben fuers Team
* (Postfachgrenze in konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen
* der letzten Wochen. Eingehaengt in routes/team/index.js unter /api/team/storage-tasks, dort hinter
* requireStaffJwt.
*
* 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
+4 -4
View File
@@ -27,7 +27,7 @@ import {
/**
* Uebernahmegutschrift.
* 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.
*/
@@ -73,7 +73,7 @@ router.get('/', async (req, res) => {
router.post('/', async (req, res) => {
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)
if (rateLimited(customerId)) {
@@ -92,7 +92,7 @@ router.post('/', async (req, res) => {
router.post('/:id/reply', async (req, res) => {
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)
if (rateLimited(customerId)) {
@@ -113,7 +113,7 @@ router.post('/:id/reply', async (req, res) => {
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()
+1 -1
View File
@@ -17,7 +17,7 @@ router.use(requireStaffJwt)
router.use('/customers', customersRoutes)
router.use('/preview-link', previewRoutes)
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('/offers', offersAdminRouter)
router.use('/storage-tasks', storageTasksAdminRouter)
+1 -1
View File
@@ -1,5 +1,5 @@
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
const router = Router()
-159
View File
@@ -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
}
+2 -1
View File
@@ -11,7 +11,8 @@ import {
/**
* 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
* 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
const COOKIE_GRACE_MS = 60 * 60 * 1000
+1 -1
View File
@@ -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.
* 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 }>}
*/
export async function sendCustomerChatNotice(customer, { sender, text }) {
+2 -2
View File
@@ -11,7 +11,7 @@ import { shortRef } from './takeoverCredits.js'
* Individuelle Abo-Angebote.
*
* 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
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
* - 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' }
// 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' }
function httpError(status, message) {
+2 -2
View File
@@ -45,8 +45,8 @@ async function currentPasswordVersion(userId) {
}
/**
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Sessions der
* Kundenansicht (Mitarbeiter) und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
*/
export async function isSessionPasswordCurrent(session) {
@@ -1,15 +1,7 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { isPreviewProjectReady } from '../../services/previewReadiness.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
const router = Router()
function isStaffAdmin(user) {
return (user?.labels || []).includes('admin')
}
import { config } from '../config.js'
import { listDocuments, Query } from './appwriteAdmin.js'
import { isPreviewProjectReady } from './previewReadiness.js'
import { createStaffPreviewToken } from './staffPreviewTokens.js'
function safePreviewUrl(url) {
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
* Admin-Route und vom Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview), genutzt vom
* Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
*/
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
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' } }
}
}
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
+2 -3
View File
@@ -21,9 +21,8 @@ export function createStaffPreviewToken(payload) {
}
/**
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
* Zweck ist trotzdem verbraucht.
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) von der Kundenansicht aus dem
* Ticketsystem ('kundenansicht'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
*/
export function consumeStaffPreviewToken(token, purpose = 'preview') {
purgeExpired()
+4 -4
View File
@@ -20,7 +20,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.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')
const { default: aboRoutes } = await import('../server/routes/abo.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)
})
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', { cookie: adminCookie() })).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)
})
test('Checkout im Modus „Als Kunde ansehen“ → 403', async () => {
const { res, call } = await checkout(orderBody('hosting'), { cookie: viewAsCookie('c3') })
test('Checkout in der Kundenansicht → 403', async () => {
const { res, call } = await checkout(orderBody('hosting'), { cookie: kundenansichtCookie({ customerId: 'c3' }) })
assert.equal(res.status, 403)
assert.equal(call, null)
})
+78
View File
@@ -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`)
}
})
+1 -1
View File
@@ -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)
// Session von vor dieser Pruefung (ohne pwv) laeuft normal aus
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)
// Konto ohne Passwort (geleert): keine Session gilt mehr
rememberPasswordVersion('u2', '')
+15 -13
View File
@@ -14,10 +14,10 @@ const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.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: 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 sf = await import('../server/services/siteFeatures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
@@ -27,7 +27,7 @@ const { mailSink } = await import('./support/mailSink.js')
const app = await startTestApp({
'/api/funktionen': funktionenRoutes,
'/api/abo': aboRoutes,
'/api/portal-admin': portalAdminRoutes,
'/api/team': teamRoutes,
'/api/public/news': publicNewsRoutes,
})
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)
})
// ---------------------------------------------------------------- Admin ---
const adminC = adminCookie()
const adminPost = (customerId, body, cookie = adminC) => post(`/api/portal-admin/funktionen/${customerId}/news`, body, { cookie })
// ------------------------------------------------- Ticketsystem (Team) ---
// Mitarbeiter ohne Admin-Label ueber den Team-Eingang (JWT)
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 () => {
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
assert.equal(view.state, 'active')
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')
assert.equal(doc.enabled, true)
assert.equal(doc.unlockedByPurchase, false)
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')
})
@@ -264,7 +266,7 @@ test('admin_abschalten', async () => {
assert.equal(res.status, 200)
const view = res.body.projects[0].features.news
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.equal((await sf.getFeatureDoc('c1', 'pa1', 'news')).enabled, false)
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
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 () => {
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.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)
})
@@ -305,7 +307,7 @@ test('ticket_fehlt_markiert', async () => {
const doc = await sf.getFeatureDoc('c1', 'pa3', 'news')
assert.equal(doc.state, 'setup')
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)
await deleteDocument(config.collections.workorders, doc.ticketId)
const missing = await newsOf()
+13 -10
View File
@@ -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'])
})
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')
assert.equal((await call('GET', '/api/news?projectId=p1', { cookie })).status, 200)
const write = await call('POST', '/api/news', { cookie, body: note })
assert.equal(write.status, 403)
assert.equal(write.body.code, 'view_blocked')
assert.equal((await call('POST', '/api/auth/logout', { cookie })).status, 200)
const before = logEntries().length
for (const [method, p, body] of [['GET', '/api/news?projectId=p1'], ['POST', '/api/news', note], ['POST', '/api/auth/logout']]) {
const res = await call(method, p, { cookie, body })
assert.equal(res.status, 401, `${method} ${p}`)
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 () => {
@@ -192,18 +195,18 @@ test('Grossschreibung umgeht den Waechter nicht: /API/... wie /api/...', async (
assert.equal(res.status, 403, `${method} ${upper}`)
}
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 }))
assert.equal(expired.status, 401)
})
test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten', async () => {
const header = { 'x-kundenansicht': VIEW }
for (const cookie of [customerCookie('c2'), viewAsCookie('c2')]) {
const res = await call('GET', '/api/projects', { cookie, headers: header })
const res = await call('GET', '/api/projects', { cookie: customerCookie('c2'), headers: header })
assert.equal(res.status, 409)
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
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
})
+4 -4
View File
@@ -18,7 +18,7 @@ const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.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 { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage, storageTaskKind } =
await import('../server/services/mailStorage.js')
@@ -1024,13 +1024,13 @@ test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde
// 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 })
const before = st.calls.length
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/preview', { units: 2 }, { cookie: viewAsCookie() })).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/preview', { units: 2 }, { cookie: kundenansichtCookie() })).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((await rawGet('/api/abo/storage')).status, 404)
})
+6 -4
View File
@@ -20,7 +20,7 @@ const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: newsRoutes } = await import('../server/routes/news.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 app = await startTestApp({ '/': kundenansichtGuard, '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
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.')
})
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)
const admin = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
assert.equal((await post('/api/news', note, { cookie: admin })).status, 403)
const alt = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
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 () => {
+4 -4
View File
@@ -20,7 +20,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.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')
const { default: aboRoutes } = await import('../server/routes/abo.js')
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)
})
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()
const cookie = customerCookie()
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.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', body, { cookie: viewAsCookie() })).status, 403)
assert.equal((await post('/api/abo/switch/preview', {}, { 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: kundenansichtCookie() })).status, 403)
assert.equal(st.calls.length, before)
})
-52
View File
@@ -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
View File
@@ -7,8 +7,8 @@ import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
/**
* Task 13: Admin-Reiter "E-Mail-Speicher" (GET/POST /api/portal-admin/storage-tasks), Schnittstelle fuers
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden des Reiters.
* "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 der Liste.
*/
// 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()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet, sessionCookie } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } =
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, seedFixtures, stripeSubscription } =
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 { listContractEvents, recordContractEvent, getContractEvent } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.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
const mails = mailSink()
setMailTransportForTests(mails)
@@ -46,7 +46,8 @@ st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage
const HOUR = 3600000
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' } }
async function clearEvents() {
@@ -77,6 +78,7 @@ beforeEach(async () => {
await clearEvents()
mails.sent.length = 0
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)
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: hostingDoc.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 () => {
// 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.packageGb, 50)
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_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 }))
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)
} finally {
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 () => {
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')
const task = before.tasks.find((t) => t.kind === 'periodenende')
await post(`/api/portal-admin/storage-tasks/${task.id}/done`, {}, ADMIN)
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
await post(`/api/team/storage-tasks/${task.id}/done`, {}, 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')
})
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].contactDomain, 'example.invalid')
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 () => {
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((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' })
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) })
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'])
})
test('Admin: Aufgabe erledigen', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(status, 200)
assert.equal(task.status, 'erledigt')
assert.equal(task.doneBy, 'kenso@webklar.com')
assert.equal(task.doneBy, 'andrej@webklar.com')
assert.ok(task.doneAt)
assert.equal(task.id, tasks[0].id)
const stored = await getContractEvent(tasks[0].id)
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
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])
})
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)
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const again = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal((await post('/api/team/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
await post(`/api/team/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)
// 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' })
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 () => {
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' } })
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')
assert.equal(partial.partial, true)
assert.equal(partial.error, 'Stripe: card_declined')
@@ -218,7 +213,7 @@ test('Admin: Wechsel-Abgleich beim Laden des Reiters (verlorener Stripe-Webhook)
consent,
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)
const ev = await getContractEvent('wechsel-abgelaufen-c1')
assert.equal(ev.status, 'gescheitert')
@@ -237,15 +232,15 @@ test('Admin: Wechsel-Abgleich laesst einen noch laufenden Wechsel in Ruhe', asyn
status: 'offen',
})
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(st.calls.length, before, 'kein Stripe-Abruf fuer einen nicht abgelaufenen Wechsel')
})
test('Ohne Admin-Session 401', async () => {
assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401)
assert.equal((await rawGet('/api/portal-admin/storage-tasks')).status, 401)
assert.equal((await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401)
test('Ohne Mitarbeiter-JWT 401 (auch mit Kunden-Cookie)', async () => {
assert.equal((await rawGet('/api/team/storage-tasks', { cookie: customerCookie() })).status, 401)
assert.equal((await rawGet('/api/team/storage-tasks')).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')
})
+2 -2
View File
@@ -92,11 +92,11 @@ export const customerCookie = (customerId = 'c1') =>
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 = () =>
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') =>
sessionCookie({
role: 'admin',
+1 -1
View File
@@ -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
* SESSION_SECRET) und express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der
* 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
* dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die
+3 -9
View File
@@ -8,17 +8,16 @@ import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.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-'))
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
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 { 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, '/api/portal-admin': portalAdminRoutes })
const app = await startTestApp({ '/api/team': teamRoutes })
const sink = mailSink()
setMailTransportForTests(sink)
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)
})
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 () => {
assert.equal((await rawGet('/api/team/chats')).status, 401)
assert.equal((await post('/api/team/chats/c1/messages', { text: 'x' })).status, 401)
-95
View File
@@ -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)