diff --git a/public/admin.html b/public/admin.html
deleted file mode 100644
index 6b44e58..0000000
--- a/public/admin.html
+++ /dev/null
@@ -1,309 +0,0 @@
-
-
-
-
-
- Admin – Webklar Kundenbereich
-
-
-
-
-
-
-
-
-
-
- Admin-Übersicht
-
-
-
- Übersicht
- Projekte
- Kunden
- Chat
- Gutschriften
- Abo-Angebote
- E-Mail-Speicher
-
-
-
-
- Lade…
-
-
-
Letzte Portal-Logins
-
-
-
-
Zuletzt aktualisierte Projekte
-
-
-
-
-
-
-
-
-
-
-
- Projekt Subdomain Kunde Status Typ Links
-
- Lade…
-
-
- Als Admin kannst du jede Projekt-Vorschau direkt öffnen – ohne Kunden-Login.
-
-
-
-
-
-
-
-
-
-
-
Unterhaltung links auswählen
-
-
-
-
-
-
-
- Offen
- Alle
-
-
-
-
- Eingang Kunde Leistung / bisher bei Rechnung Bezahlt bis Status
-
- Lade…
-
-
-
- Ü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.
-
-
-
-
-
-
- Offen & aktiv
- Alle
-
-
Neues Angebot
-
-
-
-
- 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.
-
-
-
-
- Lade…
-
-
-
Gescheiterte Änderungen (letzte 30 Tage)
-
-
- 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.
-
-
-
-
-
-
-
-
diff --git a/public/admin.js b/public/admin.js
deleted file mode 100644
index 97c079d..0000000
--- a/public/admin.js
+++ /dev/null
@@ -1,1418 +0,0 @@
-/* Admin-Dashboard des Kundenportals (Ticketsystem-Mitarbeiter). */
-
-async function api(path, options = {}) {
- const response = await fetch(path, {
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
- ...options,
- })
- const data = await response.json().catch(() => ({}))
- if (!response.ok) {
- const err = new Error(data.error || `Fehler ${response.status}`)
- err.status = response.status
- throw err
- }
- return data
-}
-
-function escapeHtml(str) {
- return String(str ?? '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
-}
-
-function fmtDate(value) {
- if (!value) return '–'
- const d = new Date(value)
- if (Number.isNaN(d.getTime())) return escapeHtml(value)
- return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
-}
-
-function fmtDateTime(value) {
- if (!value) return '–'
- const d = new Date(value)
- if (Number.isNaN(d.getTime())) return escapeHtml(value)
- return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
-}
-
-function fmtEuro(value) {
- return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
-}
-
-// Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo"); Altbestand ohne Produkt gilt als Wartungs-Abo
-const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
-const productLabelOf = (key) => PRODUCT_LABEL[key] || (key ? String(key) : 'Abo')
-
-function isLead(customer) {
- return String(customer.customerStatus || '').toLowerCase() === 'lead'
-}
-
-function stagePill(status) {
- const s = String(status || '').toLowerCase()
- if (s === 'lead') return 'Lead '
- if (s === 'lost') return 'Abgesagt '
- return 'Fester Kunde '
-}
-
-function statusPill(project) {
- const s = String(project.provisioningStatus || project.status || '').toLowerCase()
- if (['ready', 'deployed'].includes(s)) return 'deployed '
- if (s.includes('fail')) return 'Fehler '
- if (!project.subdomain) return 'ohne Website '
- return `${escapeHtml(project.status || 'pending')} `
-}
-
-function showError(message) {
- const el = document.getElementById('admin-error')
- el.textContent = message
- el.classList.remove('hidden')
-}
-
-/* ------------------------------------------------------------ Übersicht --- */
-async function loadOverview() {
- const grid = document.getElementById('stat-grid')
- const logins = document.getElementById('recent-logins')
- const recent = document.getElementById('recent-projects')
- try {
- const o = await api('/api/portal-admin/overview')
- grid.innerHTML = [
- { num: o.projects.total, lbl: 'Projekte gesamt' },
- { num: o.projects.deployed, lbl: 'Websites online' },
- { num: o.projects.withoutWebsite, lbl: 'Projekte ohne Website' },
- { num: o.customers.total, lbl: 'Kunden gesamt' },
- { num: o.customers.leads, lbl: 'Leads' },
- { num: o.customers.portalEnabled, lbl: 'Portal-Zugänge' },
- ].map((s) => `${s.num} ${s.lbl}
`).join('')
-
- logins.innerHTML = o.recentLogins.length
- ? o.recentLogins.map((l) => `${escapeHtml(l.customerName)} – ${fmtDateTime(l.lastLoginAt)} `).join('')
- : 'Noch keine Portal-Logins. '
-
- recent.innerHTML = o.recentProjects.length
- ? o.recentProjects.map((p) => `${escapeHtml(p.projectName)}${p.customerName ? ` (${escapeHtml(p.customerName)}) ` : ''} – ${fmtDateTime(p.updatedAt)} `).join('')
- : 'Keine Projekte. '
- } catch (err) {
- grid.innerHTML = ''
- showError(err.message)
- }
-}
-
-/* ------------------------------------------------------------- Projekte --- */
-let projectSearchTimer = null
-
-async function loadProjects(search = '') {
- const tbody = document.getElementById('project-rows')
- try {
- const { projects } = await api(`/api/portal-admin/projects?search=${encodeURIComponent(search)}`)
- if (!projects.length) {
- tbody.innerHTML = 'Keine Projekte gefunden. '
- return
- }
- tbody.innerHTML = projects.map((p) => `
-
- ${escapeHtml(p.projectName || p.repoFullName || '–')}
- ${p.subdomain ? escapeHtml(p.subdomain) : '– '}
- ${p.customerName ? escapeHtml(p.customerName) : '– '}
- ${statusPill(p)}
- ${p.projectType === 'repo' ? 'Repo' : 'Website'}
-
- ${p.previewUrl ? `Vorschau ` : ''}
- ${p.giteaRepoUrl ? ` · Gitea ` : ''}
-
- `).join('')
- } catch (err) {
- tbody.innerHTML = `${escapeHtml(err.message)} `
- }
-}
-
-/* --------------------------------------------------------------- Kunden --- */
-let customerSearchTimer = null
-
-async function saveCustomerCode(customerId, btn) {
- const input = document.querySelector(`input[data-code-for="${customerId}"]`)
- if (!input) return
- btn.disabled = true
- try {
- await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`, {
- method: 'PATCH',
- body: JSON.stringify({ code: input.value }),
- })
- btn.textContent = '✓ Gespeichert'
- setTimeout(() => { btn.textContent = '✓'; btn.disabled = false }, 1200)
- } catch (err) {
- btn.disabled = false
- alert('Kundennummer konnte nicht gespeichert werden: ' + err.message)
- }
-}
-
-async function viewAsCustomer(customerId) {
- try {
- await api(`/api/portal-admin/view-as/${encodeURIComponent(customerId)}`, { method: 'POST' })
- window.location.href = '/dashboard.html'
- } catch (err) {
- alert('Ansicht konnte nicht gewechselt werden: ' + err.message)
- }
-}
-
-function renderActivity(tickets) {
- if (!tickets.length) return 'Keine Tickets für diesen Kunden.
'
- return tickets.map((t) => `
-
-
${escapeHtml(t.woid ? `#${t.woid}` : '')} ${escapeHtml(t.topic || '–')}
-
${escapeHtml(t.status || '–')}
-
${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)}
- ${(t.worksheets || []).map((w) => `
-
→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}${w.supportMinutes ? `, +${escapeHtml(w.supportMinutes)} min Unterstützung` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}
- `).join('')}
-
`).join('')
-}
-
-function renderInvoices(data) {
- if (!data.linked) return 'Kunde ist nicht mit InvoiceNinja verknüpft.
'
- if (!data.invoices.length) return 'Keine Rechnungen vorhanden.
'
- return `Nr. Datum Betrag Offen Status
- ${data.invoices.map((i) => `
-
- ${escapeHtml(i.number)}
- ${fmtDate(i.date)}
- ${fmtEuro(i.amount)}
- ${fmtEuro(i.balance)}
- ${escapeHtml(i.status)}
- ${i.portalUrl ? `Ansehen ` : ''}
- `).join('')}
-
`
-}
-
-const FEATURE_STATES = {
- off: ['Aus', 'pill-muted'],
- ordered: ['Bestellt', 'pill-amber'],
- setup: ['Einrichtung', 'pill-warn'],
- active: ['Aktiv', 'pill-ok'],
- disabled: ['Abgeschaltet', 'pill-red'],
-}
-
-function featureRowHtml(p) {
- const n = p.features.news
- const [label, cls] = FEATURE_STATES[n.state] || [n.state, 'pill-muted']
- const ticket = n.ticketMissing
- ? 'Ticket fehlt '
- : n.ticketWoid ? `#${escapeHtml(n.ticketWoid)}` : '– '
- return `
-
- ${escapeHtml(p.projectName || '–')}
- ${escapeHtml(label)} ${n.adminNote ? `${escapeHtml(n.adminNote.slice(0, 60))}${n.adminNote.length > 60 ? '…' : ''}
` : ''}
- ${ticket}
-
- ${n.state === 'active' ? '' : 'Aktivieren (ohne Zahlung) '}
- ${n.state === 'off' ? '' : n.state === 'disabled' ? '' : 'Abschalten '}
-
- `
-}
-
-function renderFeatures(projects) {
- if (!projects.length) return 'Keine Projekte für diesen Kunden.
'
- return `Projekt News Ticket Aktionen
- ${projects.map(featureRowHtml).join('')}
-
`
-}
-
-function bindFeatureRows(box, customerId) {
- const message = box.querySelector('.feature-message')
- box.querySelectorAll('[data-feature-project]').forEach((row) => {
- const cell = row.querySelector('.feature-actions')
- const original = cell.innerHTML
- const bindAsk = () => cell.querySelectorAll('[data-feature-ask]').forEach((btn) =>
- btn.addEventListener('click', () => askFeature(btn.dataset.featureAsk)))
- const askFeature = (action) => {
- const text = action === 'activate'
- ? 'News ohne Zahlung aktivieren?'
- : 'News abschalten? Die Website zeigt dann keine Beiträge mehr.'
- cell.innerHTML = `${text}
-
- Ja
- Abbrechen `
- cell.querySelector('[data-feature-no]').addEventListener('click', () => {
- cell.innerHTML = original
- bindAsk()
- })
- cell.querySelector('[data-feature-yes]').addEventListener('click', async () => {
- const note = cell.querySelector('[data-feature-note]').value.trim()
- cell.querySelectorAll('button').forEach((b) => { b.disabled = true })
- try {
- const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}/news`, {
- method: 'POST',
- body: JSON.stringify({ projectId: row.dataset.featureProject, action, note }),
- })
- row.outerHTML = featureRowHtml(projects[0])
- bindFeatureRows(box, customerId)
- box.querySelector('.feature-message').textContent = 'Gespeichert.'
- } catch (err) {
- cell.innerHTML = original
- bindAsk()
- message.textContent = err.message
- message.className = 'feature-message error'
- }
- })
- }
- bindAsk()
- })
-}
-
-async function toggleCustomerDetail(customerId, kind, cell) {
- const existing = document.getElementById(`detail-${customerId}`)
- if (existing) {
- const wasKind = existing.dataset.kind
- existing.remove()
- if (wasKind === kind) return
- }
-
- const row = document.querySelector(`tr[data-customer-row="${customerId}"]`)
- if (!row) return
- const tr = document.createElement('tr')
- tr.id = `detail-${customerId}`
- tr.dataset.kind = kind
- tr.className = 'detail-row'
- tr.innerHTML = `Lade…
`
- row.after(tr)
-
- try {
- if (kind === 'activity') {
- const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`)
- tr.innerHTML = `Tickets & Arbeiten ${renderActivity(tickets)} `
- } else if (kind === 'features') {
- const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}`)
- tr.innerHTML = `Funktionen ${renderFeatures(projects)} `
- bindFeatureRows(tr, customerId)
- } else {
- const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
- tr.innerHTML = `Rechnungen ${renderInvoices(data)} `
- }
- } catch (err) {
- tr.innerHTML = `${escapeHtml(err.message)} `
- }
-}
-
-async function loadCustomers(search = '') {
- const tbody = document.getElementById('customer-rows')
- try {
- const { customers } = await api(`/api/portal-admin/customers?search=${encodeURIComponent(search)}`)
- if (!customers.length) {
- tbody.innerHTML = 'Keine Kunden gefunden. '
- return
- }
- tbody.innerHTML = customers.map((c) => `
-
-
-
-
- ✓
-
-
-
- ${escapeHtml(c.name)}
- ${escapeHtml(c.email || '–')}
-
- ${stagePill(c.customerStatus)}
- ${c.portalAccessEnabled ? 'aktiv ' : 'kein Zugang '}
- ${fmtDateTime(c.lastLoginAt)}
- ${c.projectCount}
-
-
- ${isLead(c) ? `
Rechnungsanalyse ` : ''}
-
Als Kunde ansehen
-
Arbeiten
-
Funktionen
-
Rechnungen
-
Angebot
-
-
- `).join('')
-
- tbody.querySelectorAll('[data-save-code]').forEach((btn) =>
- btn.addEventListener('click', () => saveCustomerCode(btn.dataset.saveCode, btn)))
- tbody.querySelectorAll('[data-view-as]').forEach((btn) =>
- btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs)))
- tbody.querySelectorAll('[data-activity]').forEach((btn) =>
- btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity')))
- tbody.querySelectorAll('[data-features]').forEach((btn) =>
- btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.features, 'features')))
- tbody.querySelectorAll('[data-invoices]').forEach((btn) =>
- btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices')))
- tbody.querySelectorAll('[data-offer-for]').forEach((btn) =>
- btn.addEventListener('click', () => startOfferFor(btn.dataset.offerFor)))
- } catch (err) {
- tbody.innerHTML = `${escapeHtml(err.message)} `
- }
-}
-
-/* ----------------------------------------------------------------- Chat --- */
-const adminChat = {
- activeCustomerId: '',
- activeCustomerName: '',
- lastCreatedAt: '',
- threadTimer: null,
- shownIds: new Set(),
- composer: null,
-}
-
-const adminChatUrl = (customerId) => `/api/portal-admin/chats/${encodeURIComponent(customerId)}`
-
-function updateAdminChatBadge(unread) {
- const badge = document.getElementById('admin-chat-badge')
- if (!badge) return
- if (unread > 0) {
- badge.textContent = unread
- badge.classList.remove('hidden')
- } else {
- badge.classList.add('hidden')
- }
-}
-
-async function pollAdminChatBadge() {
- if (document.hidden) return
- try {
- const { unread } = await api('/api/portal-admin/chats/unread-count')
- updateAdminChatBadge(unread)
- } catch {
- /* ignore */
- }
-}
-
-// Neue Unterhaltung: jeden Kunden anschreiben, auch wenn er noch nie geschrieben hat
-const chatCustomerLabel = (c) =>
- [c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
-
-async function setupNewChatPicker() {
- const input = document.getElementById('admin-chat-new')
- if (!input) return
- let byLabel = new Map()
- try {
- const { customers } = await api('/api/portal-admin/customers')
- byLabel = new Map(customers.map((c) => [chatCustomerLabel(c), c]))
- document.getElementById('admin-chat-new-list').innerHTML = customers
- .map((c) => ` `).join('')
- } catch (err) {
- input.placeholder = `Kunden konnten nicht geladen werden: ${err.message}`
- return
- }
- const pick = () => {
- const customer = byLabel.get(input.value.trim())
- if (!customer) return
- input.value = ''
- openChatThread(customer.id, customer.name || customer.email || 'Kunde')
- }
- input.addEventListener('change', pick)
- input.addEventListener('input', pick)
-}
-
-async function loadChatList() {
- const list = document.getElementById('admin-chat-list')
- try {
- const { chats } = await api('/api/portal-admin/chats')
- if (!chats.length) {
- list.innerHTML = 'Noch keine Unterhaltungen.
'
- return
- }
- list.innerHTML = chats.map((c) => `
-
-
- ${escapeHtml(c.customerName)}
- ${c.unreadFromCustomer ? `${c.unreadFromCustomer} ` : ''}
-
-
${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}
-
${fmtDateTime(c.lastAt)}
-
`).join('')
- list.querySelectorAll('[data-chat-customer]').forEach((el) =>
- el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName)))
- } catch (err) {
- list.innerHTML = `${escapeHtml(err.message)}
`
- }
-}
-
-function appendAdminChatMessages(messages) {
- const box = document.getElementById('admin-chat-messages')
- if (!box || !messages.length) return
- const base = adminChatUrl(adminChat.activeCustomerId)
- for (const msg of messages) {
- if (adminChat.shownIds.has(msg.id)) continue
- adminChat.shownIds.add(msg.id)
- const mine = msg.sender === 'admin'
- box.appendChild(ChatComposer.renderMessage(msg, {
- mine,
- meta: `${mine ? msg.senderName || 'Admin' : adminChat.activeCustomerName} · ${fmtDateTime(msg.createdAt)}`,
- fileUrl: (att) => `${base}/attachments/${encodeURIComponent(att.id)}`,
- }))
- if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt
- }
- box.scrollTop = box.scrollHeight
-}
-
-async function pollAdminThread() {
- if (!adminChat.activeCustomerId || document.hidden) return
- try {
- const customerId = adminChat.activeCustomerId
- const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : ''
- const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages${q}`)
- if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
- if (messages.length) {
- appendAdminChatMessages(messages)
- await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {})
- }
- } catch {
- /* naechster Poll */
- }
-}
-
-// Ergebnis des Mail-Hinweises an den Kunden (K17) unter dem Namen anzeigen
-function showAdminChatNotice(notice) {
- const el = document.getElementById('admin-chat-notice')
- if (!el || !notice) return
- const time = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
- el.textContent = notice.sent
- ? `E-Mail-Hinweis an ${notice.to} gesendet (${time})`
- : notice.reason === 'throttled'
- ? 'Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.'
- : `Kein E-Mail-Hinweis: ${notice.reason}`
-}
-
-async function openChatThread(customerId, customerName) {
- adminChat.activeCustomerId = customerId
- adminChat.activeCustomerName = customerName || 'Kunde'
- adminChat.lastCreatedAt = ''
- adminChat.shownIds = new Set()
- if (adminChat.composer) adminChat.composer.destroy()
-
- const thread = document.getElementById('admin-chat-thread')
- thread.innerHTML = `
- ${escapeHtml(adminChat.activeCustomerName)}
-
-
-
`
- ChatComposer.trackScroll(document.getElementById('admin-chat-messages'))
-
- // Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
- adminChat.composer = ChatComposer.create({
- root: document.getElementById('admin-chat-composer'),
- dropZone: thread,
- uploadUrl: `${adminChatUrl(customerId)}/attachments`,
- placeholder: `Antwort an ${adminChat.activeCustomerName}…`,
- placeholderShort: 'Antwort…',
- send: async ({ text, attachmentIds }) => {
- const { message, notice } = await api(`${adminChatUrl(customerId)}/messages`, {
- method: 'POST',
- body: JSON.stringify({ text, attachmentIds }),
- })
- if (adminChat.activeCustomerId === customerId) showAdminChatNotice(notice)
- return message
- },
- onSent: (message) => {
- if (adminChat.activeCustomerId !== customerId) return
- appendAdminChatMessages([message])
- loadChatList()
- },
- })
-
- try {
- const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`)
- if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
- appendAdminChatMessages(messages)
- await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {})
- updateAdminChatBadge(0)
- pollAdminChatBadge()
- loadChatList()
- } catch (err) {
- document.getElementById('admin-chat-messages').innerHTML = `${escapeHtml(err.message)} `
- }
-
- clearInterval(adminChat.threadTimer)
- adminChat.threadTimer = setInterval(pollAdminThread, 15000)
-}
-
-/* ---------------------------------------------- Übernahmegutschriften --- */
-const CREDIT_STATUS = {
- offen: ['pill-warn', 'Zu prüfen'],
- rueckfrage: ['pill-muted', 'Rückfrage beim Kunden'],
- genehmigt: ['pill-ok', 'Genehmigt'],
- abgelehnt: ['pill-red', 'Abgelehnt'],
-}
-
-const CREDIT_HISTORY_LABELS = {
- eingereicht: 'Antrag eingereicht',
- rueckfrage: 'Rückfrage an den Kunden',
- antwort: 'Antwort des Kunden',
- genehmigt: 'Genehmigt',
- abgelehnt: 'Abgelehnt',
- verrechnet: 'In Stripe gutgeschrieben',
- 'nachweise-geloescht': 'Nachweise gelöscht',
-}
-
-const CREDIT_MONTHS = [1, 3, 6, 12, 24, 36]
-
-const creditState = { filter: 'open', activeId: '', detail: null, calcTimer: null, amountTouched: false }
-
-const euroFromCents = (cents) => fmtEuro((Number(cents) || 0) / 100)
-
-function fmtBytes(bytes) {
- const n = Number(bytes || 0)
- return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`
-}
-
-function creditStatusPill(status) {
- const [cls, label] = CREDIT_STATUS[status] || ['pill-muted', status]
- return `${label} `
-}
-
-function updateCreditBadge(open) {
- const badge = document.getElementById('admin-credit-badge')
- if (!badge) return
- badge.textContent = open
- badge.classList.toggle('hidden', !(open > 0))
-}
-
-async function pollCreditBadge() {
- if (document.hidden) return
- try {
- const { open } = await api('/api/portal-admin/takeover-credits/open-count')
- updateCreditBadge(open)
- } catch {
- /* ignore */
- }
-}
-
-async function loadCredits() {
- const tbody = document.getElementById('credit-rows')
- try {
- const { requests, openCount } = await api(`/api/portal-admin/takeover-credits?status=${creditState.filter}`)
- updateCreditBadge(openCount)
- if (!requests.length) {
- tbody.innerHTML = `${creditState.filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'} `
- return
- }
- tbody.innerHTML = requests.map((r) => `
-
- ${fmtDateTime(r.createdAt)}${escapeHtml(r.ref)}
- ${escapeHtml(r.customerName)} ${r.customerCode ? `Kd.-Nr. ${escapeHtml(r.customerCode)} ` : ''}
- ${escapeHtml(r.services)}${escapeHtml(r.provider)}${r.domain ? ` · ${escapeHtml(r.domain)}` : ''}
- ${euroFromCents(r.invoiceAmountCents)}für ${r.billingMonths} Mon.
- ${fmtDate(r.paidUntil)}
- ${creditStatusPill(r.status)}${r.status === 'genehmigt' ? `${euroFromCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'} ` : ''}
- ${['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}
- `).join('')
- tbody.querySelectorAll('[data-open-credit]').forEach((btn) =>
- btn.addEventListener('click', () => openCreditDetail(btn.dataset.openCredit)))
- } catch (err) {
- tbody.innerHTML = `${escapeHtml(err.message)} `
- }
-}
-
-function setCreditUrl(id) {
- const url = new URL(window.location.href)
- if (id) url.searchParams.set('gutschrift', id)
- else url.searchParams.delete('gutschrift')
- window.history.replaceState(null, '', url)
-}
-
-function closeCreditDetail() {
- creditState.activeId = ''
- document.getElementById('credit-detail').classList.add('hidden')
- document.querySelectorAll('[data-credit-row]').forEach((tr) => tr.classList.remove('credit-active'))
- setCreditUrl('')
-}
-
-async function openCreditDetail(id, { message = '' } = {}) {
- creditState.activeId = id
- creditState.amountTouched = false
- const panel = document.getElementById('credit-detail')
- panel.classList.remove('hidden')
- panel.innerHTML = 'Lade Antrag…
'
- document.querySelectorAll('[data-credit-row]').forEach((tr) =>
- tr.classList.toggle('credit-active', tr.dataset.creditRow === id))
- setCreditUrl(id)
- try {
- const d = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}`)
- creditState.detail = d
- renderCreditDetail(panel, d, message)
- } catch (err) {
- panel.innerHTML = `${escapeHtml(err.message)}
`
- }
- panel.scrollIntoView({ behavior: 'smooth', block: 'start' })
-}
-
-function calcSummary(calc) {
- return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` +
- `Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${euroFromCents(calc.suggestedCents)}`
-}
-
-function creditDecisionFormHtml(d) {
- const r = d.request
- const suggested = d.calc ? d.calc.suggestedCents : 0
- return `
- Prüfung durch dich
-
- ${d.requiredChecks.map((k) => ` ${escapeHtml(k.label)} `).join('')}
-
- Berechnung
-
-
Rechnungsbetrag (€)
-
-
Rechnung gilt für
- ${CREDIT_MONTHS.map((m) => `${m} Monat${m === 1 ? '' : 'e'} `).join('')}
-
Bezahlt bis
-
-
Übernahme ab
-
-
- ${d.calc ? escapeHtml(calcSummary(d.calc)) : 'Angaben unvollständig'}
- Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.
- Gutschrift (€)
-
- Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
-
-
- Genehmigen & gutschreiben
- Rückfrage senden
- Ablehnen
-
- Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.${d.subscription?.active ? '' : ' Achtung: Der Kunde hat kein laufendes Abo. '}
-
`
-}
-
-function creditDecisionSummaryHtml(d) {
- const r = d.request
- if (r.status === 'genehmigt') {
- return `
- Entscheidung
-
- Genehmigt ${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
- Gutschrift ${euroFromCents(r.amountCents)}
- Berechnung ${escapeHtml(r.calculation)}
- Geprüft ${r.checks ? `alle Punkte abgehakt (${escapeHtml(r.checks.by || '')})` : '–'}
- ${r.decisionNote ? `Nachricht ${escapeHtml(r.decisionNote)} ` : ''}
- Verrechnung ${r.appliedAt
- ? `Stripe-Guthaben gebucht am ${fmtDateTime(r.appliedAt)} (${escapeHtml(r.stripeBalanceTransactionId)}) `
- : 'noch nicht in Stripe gebucht '}
-
- ${r.appliedAt ? '' : `
- Jetzt in Stripe gutschreiben
-
`}`
- }
- return `
- Entscheidung
-
- Abgelehnt ${fmtDateTime(r.decidedAt)} · ${escapeHtml(r.decidedBy)}
- Begründung ${escapeHtml(r.decisionNote)}
- `
-}
-
-function renderCreditDetail(panel, d, message) {
- const r = d.request
- const c = d.customer
- const s = d.subscription
- const pending = r.status === 'offen' || r.status === 'rueckfrage'
- const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
- const fileHref = (f) => `/api/portal-admin/takeover-credits/${encodeURIComponent(r.id)}/files/${encodeURIComponent(f.id)}`
- const aboPill = !s
- ? 'kein Abo '
- : `${escapeHtml(s.status)} `
- const staff = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${escapeHtml(by)}` : '')
-
- panel.innerHTML = `
-
-
-
Antrag ${escapeHtml(r.ref)} · ${escapeHtml(c.name || c.companyName)}
- eingegangen ${fmtDateTime(r.createdAt)} ${creditStatusPill(r.status)}
-
-
Schließen
-
- ${message ? `${escapeHtml(message)}
` : ''}
-
-
-
Antrag
-
- Leistung ${escapeHtml(r.services)}
- Bisher bei ${escapeHtml(r.provider)}
- Domain ${r.domain ? `${escapeHtml(r.domain)} ` : '–'}
- Rechnung ${euroFromCents(r.invoiceAmountCents)} für ${r.billingMonths} Monat${r.billingMonths === 1 ? '' : 'e'}
- Bezahlt bis ${fmtDate(r.paidUntil)}
- Antwort an ${escapeHtml(r.email)}
- Bestätigt Angaben und Bedingungen (Stand ${escapeHtml(r.conditionsVersion || '–')}) am ${fmtDateTime(r.consentAt)}
-
-
Grund / Nachricht
-
${escapeHtml(r.message)}
-
Nachweise
- ${r.files.length
- ? `
`
- : '
Keine Nachweise (mehr) vorhanden.
'}
-
Verlauf
- ${r.history.map((h) => `
-
-
${escapeHtml(CREDIT_HISTORY_LABELS[h.type] || h.type)}
-
${fmtDateTime(h.at)}${staff(h.by)}
- ${h.text ? `
${escapeHtml(h.text)}
` : ''}
- ${(h.files || []).length ? `
Dateien: ${h.files.map((id) => escapeHtml(fileNames.get(id) || 'gelöscht')).join(', ')}
` : ''}
-
`).join('')}
-
-
-
Kunde & Vertrag
-
- Kunde ${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}
- E-Mail ${escapeHtml(c.email || '–')}
- Abo ${s ? `${escapeHtml(productLabelOf(s.productKey))} ${aboPill}Zusatzspeicher: ${storageLabel(s.storageUnits)} ` : aboPill}
- ${s ? `
- Leistungsbeginn ${fmtDate(s.serviceStart)}
- Nächste Abbuchung ${s.active ? fmtDate(s.currentPeriodEnd) : '–'}
- ${s.cancelAt ? `Gekündigt zum ${fmtDate(s.cancelAt)} ` : ''}
- Stripe-Guthaben ${s.creditBalanceCents == null ? '–' : euroFromCents(s.creditBalanceCents)} ` : ''}
-
-
- ${d.otherRequests.length ? `
-
Weitere Anträge des Kunden
-
${d.otherRequests.map((o) => `
- ${escapeHtml(o.ref)} · ${escapeHtml(o.services)} · ${fmtDate(o.createdAt)}
- ${creditStatusPill(o.status)}${o.amountCents ? ` ${euroFromCents(o.amountCents)}` : ''} `).join('')} ` : ''}
-
Automatische Prüfung
-
${d.autoChecks.map((k) => `
- ${escapeHtml(k.label)}${k.detail ? `${escapeHtml(k.detail)} ` : ''} `).join('')}
- ${pending ? creditDecisionFormHtml(d) : creditDecisionSummaryHtml(d)}
-
-
`
-
- panel.querySelector('#credit-close')?.addEventListener('click', closeCreditDetail)
- panel.querySelector('#credit-view-as')?.addEventListener('click', () => viewAsCustomer(c.id))
- panel.querySelectorAll('[data-open-credit]').forEach((a) =>
- a.addEventListener('click', (e) => {
- e.preventDefault()
- openCreditDetail(a.dataset.openCredit)
- }))
- panel.querySelector('#credit-apply')?.addEventListener('click', () => applyCredit(r.id))
- if (pending) bindCreditDecision(panel, r.id)
-}
-
-function refreshApproveState(panel) {
- const btn = panel.querySelector('#credit-approve')
- if (!btn) return
- const allChecked = [...panel.querySelectorAll('[data-check]')].every((cb) => cb.checked)
- btn.disabled = !allChecked || !(Number(panel.querySelector('#credit-amount').value) > 0)
-}
-
-function calcFigures(panel) {
- return {
- invoiceAmountCents: Math.round(Number(panel.querySelector('#calc-amount').value) * 100),
- billingMonths: Number(panel.querySelector('#calc-months').value),
- paidUntil: panel.querySelector('#calc-paid-until').value,
- takeoverFrom: panel.querySelector('#calc-from').value,
- }
-}
-
-async function recalcCredit(panel) {
- const out = panel.querySelector('#calc-result')
- try {
- const { calc } = await api('/api/portal-admin/takeover-credits/calc', {
- method: 'POST',
- body: JSON.stringify(calcFigures(panel)),
- })
- out.textContent = calcSummary(calc)
- if (!creditState.amountTouched) panel.querySelector('#credit-amount').value = (calc.suggestedCents / 100).toFixed(2)
- } catch (err) {
- out.textContent = err.message
- }
- refreshApproveState(panel)
-}
-
-function bindCreditDecision(panel, id) {
- panel.querySelectorAll('[data-check]').forEach((cb) => cb.addEventListener('change', () => refreshApproveState(panel)))
- panel.querySelector('#credit-amount').addEventListener('input', () => {
- creditState.amountTouched = true
- refreshApproveState(panel)
- })
- panel.querySelectorAll('#calc-amount, #calc-months, #calc-paid-until, #calc-from').forEach((el) =>
- el.addEventListener('input', () => {
- clearTimeout(creditState.calcTimer)
- creditState.calcTimer = setTimeout(() => recalcCredit(panel), 300)
- }))
- panel.querySelector('#credit-approve').addEventListener('click', () => submitCreditDecision(id, 'approve', panel))
- panel.querySelector('#credit-question').addEventListener('click', () => submitCreditDecision(id, 'question', panel))
- panel.querySelector('#credit-reject').addEventListener('click', () => submitCreditDecision(id, 'reject', panel))
-}
-
-async function submitCreditDecision(id, action, panel) {
- const errEl = panel.querySelector('#credit-error')
- errEl.classList.add('hidden')
- const note = panel.querySelector('#credit-note').value.trim()
- const amountCents = Math.round(Number(panel.querySelector('#credit-amount').value) * 100)
- const who = creditState.detail?.customer?.name || 'den Kunden'
- if (action !== 'approve' && note.length < 5) {
- errEl.textContent = action === 'question'
- ? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.'
- : 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.'
- errEl.classList.remove('hidden')
- return
- }
- const question = {
- approve: `Gutschrift über ${euroFromCents(amountCents)} genehmigen, in Stripe gutschreiben und ${who} per E-Mail informieren?`,
- question: `Rückfrage per E-Mail an ${who} senden?`,
- reject: `Antrag ablehnen und ${who} die Begründung per E-Mail senden?`,
- }[action]
- if (!window.confirm(question)) return
-
- const buttons = panel.querySelectorAll('.credit-actions button')
- buttons.forEach((b) => { b.disabled = true })
- try {
- const checks = Object.fromEntries([...panel.querySelectorAll('[data-check]')].map((cb) => [cb.dataset.check, cb.checked]))
- const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/decision`, {
- method: 'POST',
- body: JSON.stringify({ action, note, amountCents, checks, ...calcFigures(panel) }),
- })
- const parts = [{ genehmigt: 'Genehmigt.', rueckfrage: 'Rückfrage gesendet.', abgelehnt: 'Abgelehnt.' }[result.status] || 'Gespeichert.']
- if (action === 'approve') {
- parts.push(result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Noch nicht in Stripe gebucht: ${result.reason}`)
- }
- parts.push(result.mailSent
- ? 'Die E-Mail an den Kunden ist raus.'
- : `E-Mail an den Kunden fehlgeschlagen (${result.mailError || 'unbekannt'}) – bitte selbst informieren.`)
- await loadCredits()
- await openCreditDetail(id, { message: parts.join(' ') })
- } catch (err) {
- errEl.textContent = err.message
- errEl.classList.remove('hidden')
- buttons.forEach((b) => { b.disabled = false })
- refreshApproveState(panel)
- }
-}
-
-async function applyCredit(id) {
- const panel = document.getElementById('credit-detail')
- const btn = panel.querySelector('#credit-apply')
- btn.disabled = true
- try {
- const result = await api(`/api/portal-admin/takeover-credits/${encodeURIComponent(id)}/apply`, { method: 'POST' })
- await loadCredits()
- await openCreditDetail(id, {
- message: result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`,
- })
- } catch (err) {
- const errEl = panel.querySelector('#credit-error')
- errEl.textContent = err.message
- errEl.classList.remove('hidden')
- btn.disabled = false
- }
-}
-
-/* ----------------------------------------------------- E-Mail-Speicher --- */
-const GB_INCLUDED = 5
-const GB_PER_UNIT = 5
-const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0)
-// "2 Pakete (15 GB)" / "kein Paket (5 GB)"
-function storageLabel(units) {
- const n = Number(units) || 0
- const packages = n === 0 ? 'kein Paket' : n === 1 ? '1 Paket' : `${n} Pakete`
- return `${packages} (${gbOfUnits(n)} GB)`
-}
-
-function storageDueHtml(t) {
- const overdue = t.dueAt && new Date(t.dueAt).getTime() <= Date.now()
- if (t.kind === 'einrichten') {
- return `jetzt einrichten bestellt ${fmtDateTime(t.createdAt)} `
- }
- return `zum Periodenende ${fmtDate(t.dueAt)}`
-}
-
-function storageTaskHtml(t) {
- const action = t.kind === 'einrichten'
- ? `Postfachgrenze in konsoleH jetzt auf ${escapeHtml(t.targetGb)} GB setzen`
- : `Postfachgrenze in konsoleH am ${fmtDate(t.dueAt)} auf ${escapeHtml(t.targetGb)} GB senken`
- const partial = t.partial
- ? `Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''} `
- : ''
- const scope = `Gesamtspeicher des Kunden: ${escapeHtml(t.targetGb)} GB , auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${escapeHtml(t.contactDomain)}` : ''} `
- return `${action}${scope}${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''} ${partial}
`
-}
-
-async function loadStorageTasks() {
- const summary = document.getElementById('storage-summary')
- const tbody = document.getElementById('storage-rows')
- const failedBox = document.getElementById('storage-failed')
- const failedList = document.getElementById('storage-failed-list')
- try {
- const d = await api('/api/portal-admin/storage-tasks')
- const warn = Number(d.allocatedGb) >= Number(d.warnGb)
- summary.classList.toggle('warn', warn)
- summary.innerHTML = `${escapeHtml(d.allocatedGb)} von ${escapeHtml(d.packageGb)} GB vergeben (Paket ${escapeHtml(d.packageName)})${
- warn ? ` – Warnschwelle ${escapeHtml(d.warnGb)} GB erreicht: größeres Hetzner-Paket buchen. ` : ` · Warnung ab ${escapeHtml(d.warnGb)} GB`
- }`
- tbody.innerHTML = d.tasks.length
- ? d.tasks.map((t) => `
-
- ${storageDueHtml(t)}
- ${escapeHtml(t.customerName)} ${t.customerCode ? `Kd.-Nr. ${escapeHtml(t.customerCode)} ` : ''}${t.email ? `${escapeHtml(t.email).replace('@', '@')} ` : ''}${escapeHtml(t.productLabel)}
- ${storageTaskHtml(t)}
- ${fmtDateTime(t.createdAt)}${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}
- Erledigt
- `).join('')
- : 'Keine offenen Speicher-Aufgaben. '
- failedBox.classList.toggle('hidden', !d.failed.length)
- failedList.innerHTML = d.failed.map((f) => `
- ${fmtDateTime(f.createdAt)} · ${escapeHtml(f.customerName)} · ${escapeHtml(storageLabel(f.fromUnits))} → ${escapeHtml(storageLabel(f.toUnits))}
- – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${escapeHtml(f.error)}` : ''} `).join('')
- tbody.querySelectorAll('[data-storage-done]').forEach((btn) => btn.addEventListener('click', () => completeStorageTask(btn.dataset.storageDone, btn)))
- } catch (err) {
- summary.classList.remove('warn')
- summary.textContent = ''
- tbody.innerHTML = `${escapeHtml(err.message)} `
- }
-}
-
-async function completeStorageTask(id, btn) {
- if (btn.dataset.partial === '1' && !confirm('Stripe-Menge von Hand korrigiert?')) return
- btn.disabled = true
- try {
- await api(`/api/portal-admin/storage-tasks/${encodeURIComponent(id)}/done`, { method: 'POST', body: '{}' })
- await loadStorageTasks()
- } catch (err) {
- btn.disabled = false
- alert('Aufgabe konnte nicht als erledigt gespeichert werden: ' + err.message)
- }
-}
-
-/* ------------------------------------------------------- Abo-Angebote --- */
-const OFFER_STATUS = {
- offen: ['pill-warn', 'offen'],
- aktiv: ['pill-ok', 'aktiv'],
- abgelaufen: ['pill-muted', 'abgelaufen'],
- zurueckgezogen: ['pill-muted', 'zurückgezogen'],
-}
-
-const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
-
-const OFFER_PRODUCT_LABEL = PRODUCT_LABEL
-
-function offerStatusPill(o) {
- const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
- const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
- return `${label} ${sub ? `${sub} ` : ''}`
-}
-
-// Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable)
-function offerMailStatus(o) {
- if (o.notifiedAt) return `E-Mail gesendet ${fmtDateTime(o.notifiedAt)} `
- if (o.mailSendable) return 'E-Mail nicht verschickt '
- if (o.status === 'offen' && !o.customerEmail) return 'Keine E-Mail-Adresse hinterlegt '
- return ''
-}
-
-// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt
-function offerPreviewText(price, months, regular, running) {
- const eur = (cents) => euroFromCents(cents)
- const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate`
- const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${eur(price)} statt ${eur(regular)}`
- return `${lead} ${amount}, danach ${eur(regular)} pro Monat`
-}
-
-// Datumsfeld "2026-10-27" -> "27.10.2026", ohne Umweg über Date (keine Zeitzonen-Verschiebung)
-const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.')
-// Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD); Abbuchungen und Fristen zählen nach deutschem Kalender
-const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
-const fmtBerlin = (value) => fmtYmd(berlinYmd(value))
-
-// Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.)
-function addMonthsYmd(ymd, n) {
- const [y, m, d] = ymd.split('-').map(Number)
- const last = new Date(Date.UTC(y, m + n, 0)).getUTCDate()
- return new Date(Date.UTC(y, m - 1 + n, Math.min(d, last))).toISOString().slice(0, 10)
-}
-
-/* Rabattende eines noch nicht eingelösten Angebots: Der Rabatt läuft N Monate ab Abschluss, der Kunde
- kann heute oder spätestens am letzten Gültigkeitstag abschließen. Mit Beginn nach der Widerrufsfrist
- starten Leistung und Rabatt gut 2 Wochen später (deadlines.js lateStartAt). */
-function offerDiscountEstimate(months, validUntilYmd, testAccount = false) {
- const today = berlinYmd(new Date())
- // Testkonto: Coupon kommt erst nach der ersten Abbuchung ans Abo (offers.js), der Rabatt endet einen Monat später
- const n = months + (testAccount ? 1 : 0)
- return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) }
-}
-
-// Zeile "Rabatt endet …" in der Angebotsliste
-function offerDiscountLine(o) {
- if (o.status === 'offen' && o.validUntil) {
- const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount)
- return earliest === latest
- ? `Rabatt endet voraussichtlich am ${fmtYmd(earliest)}`
- : `Rabatt endet voraussichtlich zwischen ${fmtYmd(earliest)} und ${fmtYmd(latest)}`
- }
- if (o.status !== 'aktiv' || !o.discount) return ''
- const d = o.discount
- if (!d.known) return 'Rabattende unbekannt (Abo nicht gefunden)'
- const end = d.subscriptionEndsAt || d.endsAt
- const past = new Date(end) <= new Date()
- return d.subscriptionEndsAt
- ? `Rabatt ${past ? 'endete' : 'endet'} mit dem Abo am ${fmtBerlin(end)}`
- : `Rabatt ${past ? 'endete' : 'endet'} am ${fmtBerlin(end)}`
-}
-
-function showOfferMessage(text) {
- const el = document.getElementById('offer-message')
- el.textContent = text
- el.classList.toggle('hidden', !text)
-}
-
-async function loadOffers() {
- const tbody = document.getElementById('offer-rows')
- try {
- const { offers } = await api('/api/portal-admin/offers')
- offerState.offers = new Map(offers.map((o) => [o.id, o]))
- const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
- if (!shown.length) {
- tbody.innerHTML = `${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'} `
- return
- }
- tbody.innerHTML = shown.map((o) => {
- const discount = offerDiscountLine(o)
- return `
-
- ${fmtDateTime(o.createdAt)}${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}
- ${escapeHtml(o.customerName)} ${o.customerEmail ? `${escapeHtml(o.customerEmail).replace('@', '@')} ` : ''}${o.customerCode ? `Kd.-Nr. ${escapeHtml(o.customerCode)} ` : ''}
- ${escapeHtml(productLabelOf(o.productKey))} ${escapeHtml(o.text)}Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}
- ${discount ? `${escapeHtml(discount)} ` : ''}
- ${o.internalNote ? `intern: ${escapeHtml(o.internalNote)} ` : ''}
- ${o.scope === 'laufend' ? 'laufendes Abo ' : fmtDate(o.validUntil)}
- ${offerStatusPill(o)}
- Stripe: ${o.stripeCouponUrl ? `Coupon ` : '–'}${o.stripeSubscriptionUrl ? ` · Abo ` : ''}
- ${offerMailStatus(o)}
- ${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `
- ${o.mailSendable ? `${o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'} ` : ''}
- ${['offen', 'abgelaufen'].includes(o.status) ? `Zurückziehen ` : ''}
` : ''}
- `
- }).join('')
- tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
- btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
- tbody.querySelectorAll('[data-send-offer-mail]').forEach((btn) =>
- btn.addEventListener('click', () => sendOfferMailAction(btn.dataset.sendOfferMail, btn)))
- } catch (err) {
- tbody.innerHTML = `${escapeHtml(err.message)} `
- }
-}
-
-async function withdrawOfferAction(id, btn) {
- if (!window.confirm('Angebot zurückziehen? Der Coupon wird in Stripe gelöscht, der Kunde sieht das Angebot nicht mehr.')) return
- btn.disabled = true
- try {
- await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/withdraw`, { method: 'POST' })
- showOfferMessage('Angebot zurückgezogen, Coupon in Stripe gelöscht.')
- loadOffers()
- } catch (err) {
- btn.disabled = false
- alert('Zurückziehen fehlgeschlagen: ' + err.message)
- }
-}
-
-async function sendOfferMailAction(id, btn) {
- const o = offerState.offers.get(id)
- if (!o) return
- const again = o.notifiedAt ? `\n\nZuletzt gesendet ${fmtDateTime(o.notifiedAt)}.` : ''
- if (!window.confirm(`E-Mail mit dem Angebot an ${o.customerName} (${o.customerEmail}) ${o.notifiedAt ? 'erneut ' : ''}senden?${again}`)) return
- btn.disabled = true
- try {
- await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/notify`, { method: 'POST' })
- showOfferMessage(`Die E-Mail an ${o.customerEmail} ist raus, der Mailserver hat sie angenommen. Kommt sie nicht an: Spam-Ordner prüfen, bei Gmail auch den Tab „Werbung“.`)
- loadOffers()
- } catch (err) {
- btn.disabled = false
- alert(err.message)
- }
-}
-
-const offerCustomerLabel = (c) =>
- [c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
-
-async function ensureOfferCustomers() {
- if (offerState.customers) return offerState.customers
- const { customers } = await api('/api/portal-admin/customers')
- offerState.customers = customers
- offerState.labelToId = new Map(customers.map((c) => [offerCustomerLabel(c), c.id]))
- return customers
-}
-
-function offerFormValues(form) {
- return {
- customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '',
- productKey: form.querySelector('#offer-product').value,
- monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100),
- months: Number(form.querySelector('#offer-months').value),
- validUntil: form.querySelector('#offer-valid').value,
- note: form.querySelector('#offer-note').value.trim(),
- internalNote: form.querySelector('#offer-internal').value.trim(),
- notifyCustomer: form.querySelector('#offer-notify').checked,
- }
-}
-
-function renderOfferPreview(form) {
- const box = form.querySelector('#offer-preview')
- const save = form.querySelector('#offer-save')
- const ctx = offerState.context
- const v = offerFormValues(form)
- save.disabled = true
- if (!v.customerId || !ctx || ctx.customer.id !== v.customerId) {
- box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.'
- return
- }
- const regular = ctx.products[v.productKey].regularPriceCents
- // Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
- const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
- const openOffer = ctx.openOffers[v.productKey]
- const lines = []
- let ok = true
- if (openOffer) {
- lines.push(`Es gibt schon ein offenes ${OFFER_PRODUCT_LABEL[v.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
- ok = false
- }
- if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) {
- lines.push(`Angebotspreis bitte zwischen 0,00 € und ${euroFromCents(regular - 1)} angeben.`)
- ok = false
- }
- if (!v.validUntil && !running) {
- lines.push('Bitte „Gültig bis“ angeben.')
- ok = false
- }
- if (ok) {
- lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`)
- if (running) {
- const charges = ctx.subscription.upcomingCharges || []
- if (charges.length > v.months) lines.push(`Rabatt endet am ${fmtBerlin(charges[v.months])} (nach ${v.months === 1 ? '1 Abbuchung' : `${v.months} Abbuchungen`}).`)
- } else {
- lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`)
- const { earliest, latest } = offerDiscountEstimate(v.months, v.validUntil, ctx.customer.testAccount)
- const lead = `Rabatt läuft ${v.months === 1 ? '1 Monat' : `${v.months} Monate`} ab Abschluss und endet voraussichtlich`
- lines.push(`${earliest === latest
- ? `${lead} am ${fmtYmd(earliest)}.`
- : `${lead} zwischen ${fmtYmd(earliest)} (Abschluss heute) und ${fmtYmd(latest)} (Abschluss am letzten Tag).`} Bei Beginn nach der Widerrufsfrist gut 2 Wochen später.`)
- }
- }
- lines.push(running
- ? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.`
- : 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
- if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
- if (ctx.customer.testAccount) lines.push(`Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt${running ? '' : ' und gilt deshalb erst ab der zweiten Abbuchung'}.`)
- box.innerHTML = lines.map((l) => `${escapeHtml(l)}
`).join('')
- save.disabled = !ok
- save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen'
-}
-
-async function loadOfferContext(form) {
- const v = offerFormValues(form)
- offerState.context = null
- renderOfferPreview(form)
- if (!v.customerId) return
- try {
- offerState.context = await api(`/api/portal-admin/offers/context/${encodeURIComponent(v.customerId)}`)
- if (!form.querySelector('#offer-valid').value) form.querySelector('#offer-valid').value = offerState.context.defaultValidUntil
- } catch (err) {
- form.querySelector('#offer-preview').textContent = err.message
- return
- }
- renderOfferPreview(form)
-}
-
-async function saveOffer(form) {
- const errEl = form.querySelector('#offer-error')
- errEl.classList.add('hidden')
- const v = offerFormValues(form)
- const ctx = offerState.context
- const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
- const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.products[v.productKey].regularPriceCents, running)
- if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(v.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`)) return
- const save = form.querySelector('#offer-save')
- save.disabled = true
- try {
- const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) })
- const o = result.offer
- form.classList.add('hidden')
- const until = o.scope !== 'laufend'
- ? `, gültig bis ${fmtDate(o.validUntil)}`
- : o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : ''
- showOfferMessage([
- `Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
- `Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
- v.notifyCustomer
- ? (result.mailSent
- ? `Die E-Mail an ${o.customerEmail} ist raus.`
- : `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`)
- : '',
- ].filter(Boolean).join(' '))
- loadOffers()
- } catch (err) {
- errEl.textContent = err.message
- errEl.classList.remove('hidden')
- renderOfferPreview(form)
- }
-}
-
-async function openOfferForm(customerId = '') {
- const form = document.getElementById('offer-form')
- showOfferMessage('')
- form.classList.remove('hidden')
- form.innerHTML = 'Lade Kunden…
'
- let customers
- try {
- customers = await ensureOfferCustomers()
- } catch (err) {
- form.innerHTML = `${escapeHtml(err.message)}
`
- return
- }
- const preselected = customers.find((c) => c.id === customerId)
- form.innerHTML = `
- Neues Angebot
-
-
Kunde
-
- ${customers.map((c) => ` `).join('')}
-
Produkt
-
- Wartungs-Abo
- Hosting-Abo
-
-
Preis pro Monat im Angebot (€)
-
-
für
- ${Array.from({ length: 12 }, (_, i) => i + 1)
- .map((m) => `${m} Monat${m === 1 ? '' : 'e'} `).join('')}
-
Angebot gültig bis
-
-
- Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)
-
- Interne Notiz (optional, sieht nur das Team)
-
- Kunden per E-Mail über das Angebot informieren
- Bitte einen Kunden aus der Liste wählen.
-
- Angebot anlegen
- Abbrechen
-
-
`
-
- form.querySelector('#offer-customer').addEventListener('change', () => loadOfferContext(form))
- form.querySelector('#offer-customer').addEventListener('input', () => {
- if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form)
- })
- form.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form)))
- form.querySelector('#offer-product').addEventListener('change', () => renderOfferPreview(form))
- form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form))
- form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden'))
- if (preselected) loadOfferContext(form)
- form.scrollIntoView({ behavior: 'smooth', block: 'start' })
-}
-
-/** Aus der Kundenliste: Tab wechseln und Formular mit dem Kunden oeffnen. */
-function startOfferFor(customerId) {
- document.querySelector('.admin-tab[data-tab="offers"]')?.click()
- openOfferForm(customerId)
-}
-
-/* ----------------------------------------------------------------- Init --- */
-async function initAdminPage() {
- document.getElementById('logout-btn')?.addEventListener('click', async () => {
- await api('/api/auth/logout', { method: 'POST' }).catch(() => {})
- window.location.href = '/login.html'
- })
-
- // Nach dem Login zurück zur aufgerufenen Adresse (z. B. Link aus der Gutschrift-Mail)
- const loginUrl = `/login.html?next=${encodeURIComponent(window.location.pathname + window.location.search)}`
- try {
- const me = await api('/api/auth/me')
- if (!me.authenticated || me.role !== 'admin') {
- window.location.href = loginUrl
- return
- }
- if (me.viewAs) {
- // Admin ist gerade in der Kundenansicht -> zurückholen
- await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
- }
- document.getElementById('admin-meta').textContent =
- `${me.admin?.name || 'Admin'} (${me.admin?.email || ''})`
- } catch {
- window.location.href = loginUrl
- return
- }
-
- let chatListLoaded = false
- let creditsLoaded = false
- let offersLoaded = false
- document.querySelectorAll('.admin-tab').forEach((tab) => {
- tab.addEventListener('click', () => {
- document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
- document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active'))
- tab.classList.add('active')
- document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active')
- if (tab.dataset.tab === 'chat' && !chatListLoaded) {
- chatListLoaded = true
- loadChatList()
- setupNewChatPicker()
- }
- if (tab.dataset.tab === 'credits' && !creditsLoaded) {
- creditsLoaded = true
- loadCredits()
- }
- if (tab.dataset.tab === 'offers' && !offersLoaded) {
- offersLoaded = true
- loadOffers()
- }
- if (tab.dataset.tab === 'storage') {
- // Bei jedem Wechsel auf den Reiter neu laden: der Stand aendert sich, sobald jemand bestellt
- loadStorageTasks()
- }
- })
- })
-
- document.querySelectorAll('[data-offer-filter]').forEach((btn) => {
- btn.addEventListener('click', () => {
- document.querySelectorAll('[data-offer-filter]').forEach((b) => b.classList.toggle('active', b === btn))
- offerState.filter = btn.dataset.offerFilter
- loadOffers()
- })
- })
- document.getElementById('offer-new')?.addEventListener('click', () => openOfferForm())
-
- document.querySelectorAll('[data-credit-filter]').forEach((btn) => {
- btn.addEventListener('click', () => {
- document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn))
- creditState.filter = btn.dataset.creditFilter
- loadCredits()
- })
- })
-
- // Direktlink aus der Admin-Mail: /admin.html?gutschrift=
- const creditFromLink = new URLSearchParams(window.location.search).get('gutschrift')
- if (creditFromLink) {
- document.querySelector('.admin-tab[data-tab="credits"]')?.click()
- openCreditDetail(creditFromLink)
- }
-
- document.getElementById('project-search')?.addEventListener('input', (e) => {
- clearTimeout(projectSearchTimer)
- projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250)
- })
- document.getElementById('customer-search')?.addEventListener('input', (e) => {
- clearTimeout(customerSearchTimer)
- customerSearchTimer = setTimeout(() => loadCustomers(e.target.value.trim()), 250)
- })
-
- loadOverview()
- loadProjects()
- loadCustomers()
-
- // Chat- und Gutschrift-Badge global pollen
- pollAdminChatBadge()
- setInterval(pollAdminChatBadge, 60000)
- pollCreditBadge()
- setInterval(pollCreditBadge, 60000)
-}
-
-window.initAdminPage = initAdminPage
diff --git a/public/app.js b/public/app.js
index 913760d..848f7d4 100644
--- a/public/app.js
+++ b/public/app.js
@@ -570,28 +570,16 @@ async function initLoginPage() {
/* --------------------------------------------------------------- View-as --- */
function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner')
- if (!banner || !me.viewAs) return
+ if (!banner || !me.viewAs || !me.viewId) return
banner.classList.remove('hidden')
- // Kundenansicht aus dem Ticketsystem (mit Kennung): Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
- if (me.viewId) {
- banner.innerHTML = `
- Kundenansicht: ${escapeHtml(me.customer?.name || 'Kunde')} · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}
- Beenden
- `
- document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
- const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
- window.location.href = redirect || 'https://ticket.webklar.com/customers'
- })
- return
- }
- // Alte Ansicht "Als Kunde ansehen" aus dem Admin-Panel (bis das Panel abgeschaltet ist)
+ // Kundenansicht aus dem Ticketsystem: Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
banner.innerHTML = `
- Admin-Ansicht: Du siehst das Portal als ${escapeHtml(me.customer?.name || 'Kunde')} .
- Zurück zur Admin-Übersicht
+ Kundenansicht: ${escapeHtml(me.customer?.name || 'Kunde')} · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}
+ Beenden
`
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
- await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
- window.location.href = '/admin.html'
+ const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
+ window.location.href = redirect || 'https://ticket.webklar.com/customers'
})
}
@@ -1973,7 +1961,7 @@ function showView(name, { updateHash = true } = {}) {
}
/* Profilbild (DiceBear Big Smile vom eigenen Server); die Initialen bleiben stehen, bis das Bild geladen ist,
- und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass "Als Kunde ansehen" nicht das gecachte Bild zeigt. */
+ und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass die Kundenansicht nicht das gecachte Bild zeigt. */
function setAvatar(el, initials, seed) {
if (!el) return
el.textContent = initials
diff --git a/public/chat-composer.css b/public/chat-composer.css
index d578ff9..8032608 100644
--- a/public/chat-composer.css
+++ b/public/chat-composer.css
@@ -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;
diff --git a/public/chat-composer.js b/public/chat-composer.js
index b90d7af..899e0ca 100644
--- a/public/chat-composer.js
+++ b/public/chat-composer.js
@@ -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 : []
diff --git a/public/kalkulation.css b/public/kalkulation.css
deleted file mode 100644
index 8a3f25f..0000000
--- a/public/kalkulation.css
+++ /dev/null
@@ -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); }
-}
diff --git a/public/kalkulation.html b/public/kalkulation.html
deleted file mode 100644
index bdb0f25..0000000
--- a/public/kalkulation.html
+++ /dev/null
@@ -1,176 +0,0 @@
-
-
-
-
-
- Rechnungsanalyse – Webklar
-
-
-
-
-
-
-
-
- Ihr Projekt
-
-
0 €
-
Endpreis · gemäß § 19 UStG ohne Umsatzsteuer
-
-
-
-
Aufwand
-
0,00 Std
-
Stundensatz 150 €/Std
-
-
-
Monatlich
-
–
-
nichts Laufendes gewählt
-
-
-
Zahlung
-
Noch nichts gewählt
-
Kreuze an, was der Kunde braucht.
-
-
- Noch offen. Sobald etwas gewählt ist, steht hier, ob du selbst abschließen kannst.
-
- Zusammenfassung
- Zurücksetzen
-
-
-
-
-
- Worum geht es?
- Bereiche antippen — nur die ausgewählten werden unten angezeigt.
-
-
-
-
-
-
-
-
Noch kein Bereich gewählt
-
Tippe oben an, worum es geht — dann erscheinen hier die Positionen.
-
-
-
-
-
-
-
- Termin
- Gespräch festlegen
- Nur wenn gewünscht: Mit wem, wann — und worum es geht. Der Termin hängt an der Zusammenfassung.
-
-
- Kein Termin nötig
- Termin gewünscht
-
-
-
-
-
-
Freie Tage
-
-
-
-
-
- Worum geht es im Gespräch?
-
-
-
-
- Noch kein Termin. Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
-
-
-
-
- Termin ins Ticket eintragen
-
- Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.
-
-
-
-
- Weitere Notizen zum Projekt
-
-
-
-
-
-
-
-
-
-
-
Kundenportal
-
Code scannen und einloggen
-
Im Portal sieht der Kunde den Stand des Projekts, alle Dateien und die Rechnungen —
- project.webklar.com
-
-
- E-Mail
-
-
-
- Passwort
-
-
-
-
-
-
-
- webklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · 0170 4969375
- Alle Preise sind Endpreise. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). Stundenwerte sind Richtwerte.
-
-
-
-
-
-
-
Zusammenfassung
-
-
- Kopieren
- Schließen
-
-
-
-
-
-
-
-
diff --git a/public/kalkulation.js b/public/kalkulation.js
deleted file mode 100644
index c8f4644..0000000
--- a/public/kalkulation.js
+++ /dev/null
@@ -1,838 +0,0 @@
-/* Rechnungsanalyse / Projektkalkulation.
- Wird aus dem Admin-Panel bei einem Lead geoeffnet (kalkulation.html?customer=)
- und im Gespraech vor Ort oder am Telefon ausgefuellt.
-
- Ergebnis ist bewusst KEIN eigener Datensatz: die Zusammenfassung wird kopiert
- und im Akquise-Ticket dokumentiert (dort wird gearbeitet). Damit ein
- angefangenes Gespraech nicht verloren geht, liegt der Stand pro Kunde im
- localStorage des Browsers. */
-
-/* --------------------------------------------------------------- Katalog --- */
-// Kleinunternehmer (§ 19 UStG): alle Preise sind Endpreise, keine Umsatzsteuer
-const USt_HINWEIS = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung).'
-
-const CATS = [
- {
- id: 'web', name: 'Website', kenso: false,
- hint: 'Alles, was zu einer Website gehört. Das Grundpaket deckt Startseite und drei Unterseiten ab.',
- groups: [
- { g: 'Grundlage', items: [
- { id: 'w1', n: 'Grundpaket Website', d: 'Startseite und bis zu 3 Unterseiten, Texte, Design, mobil, Impressum und Datenschutz', h: 2 },
- { id: 'w2', n: 'Weitere Unterseite inkl. Text', d: 'Anzahl eintragen', h: 0.25, q: true },
- { id: 'w3', n: 'Bilder beschaffen und aufbereiten', h: 0.5 },
- { id: 'w4', n: 'Logo neu gestalten', d: 'einfaches Logo, kein Corporate Design', h: 0.5 },
- { id: 'w5', n: 'Domain registrieren und einrichten', h: 0.25 },
- { id: 'w6', n: 'Bestehende Domain umziehen', d: 'Relaunch: Kunde bringt Domain mit', h: 0.5 },
- { id: 'w7', n: 'Inhalte von der alten Seite übernehmen', d: 'Relaunch', h: 1 },
- { id: 'w8', n: 'E-Mail-Postfächer einrichten', h: 0.5 },
- ] },
- { g: 'Funktionen', items: [
- { id: 'f1', n: 'Kontaktformular', h: 0.25 },
- { id: 'f2', n: 'Anfahrt, Karte, Öffnungszeiten', h: 0.25 },
- { id: 'f3', n: 'Bildergalerie oder Referenzen', h: 0.5 },
- { id: 'f4', n: 'Google-Bewertungen einbinden', h: 0.5 },
- { id: 'f5', n: 'Cookie-Banner und Consent-Tool', h: 0.5 },
- { id: 'f6', n: 'Newsletter-Anmeldung', h: 0.5 },
- { id: 'f7', n: 'Stellenanzeigen mit Bewerbungsformular', h: 0.75 },
- { id: 'f8', n: 'Blog- oder Newsbereich', h: 1 },
- { id: 'f9', n: 'Terminbuchung', h: 1.5 },
- { id: 'f10', n: 'Weitere Sprache', d: 'Anzahl der zusätzlichen Sprachen', h: 1, q: true },
- ] },
- { g: 'Sichtbarkeit', items: [
- { id: 's1', n: 'Google-Profil einrichten oder aufräumen', h: 0.75 },
- { id: 's2', n: 'SEO-Grundoptimierung', h: 1 },
- { id: 's3', n: 'Statistik und Tracking einrichten', h: 0.5 },
- { id: 's4', n: 'Google Ads einrichten', d: 'Werbebudget zahlt der Kunde selbst', h: 1.5 },
- ] },
- ],
- },
- {
- id: 'shop', name: 'Onlineshop', kenso: true,
- hint: 'Shop-Positionen stimmt Kenso mit ab — du nimmst hier nur den Bedarf auf.',
- groups: [
- { g: 'Shop', items: [
- { id: 'o1', n: 'Shop-Grundeinrichtung', d: 'Warenkorb, Kasse, Bestellabwicklung', h: 6 },
- { id: 'o2', n: 'Produkte anlegen', d: 'Anzahl in 25er-Schritten', h: 1, q: true },
- { id: 'o3', n: 'Zahlungsanbindung', h: 1.5 },
- { id: 'o4', n: 'Versand- und Steuerregeln', h: 1 },
- { id: 'o5', n: 'Gutscheine und Rabatte', h: 1 },
- { id: 'o6', n: 'Kundenkonten im Shop', h: 1.5 },
- { id: 'o7', n: 'Anbindung an die Warenwirtschaft', h: 4 },
- ] },
- ],
- },
- {
- id: 'soft', name: 'Softwareprojekt', kenso: true,
- hint: 'Eigenentwicklung: Umfang aufnehmen, den Preis bestätigt Kenso.',
- groups: [
- { g: 'Fundament', items: [
- { id: 'p1', n: 'Projektfundament', d: 'Architektur, Datenmodell, Grundgerüst, Deployment', h: 8 },
- { id: 'p2', n: 'Datenbank aufsetzen und modellieren', h: 4 },
- { id: 'p3', n: 'Benutzerkonten und Login', h: 3 },
- { id: 'p4', n: 'Rollen und Rechte', h: 2.5 },
- ] },
- { g: 'Oberfläche und Verwaltung', items: [
- { id: 'a1', n: 'Admin-Panel Grundgerüst', h: 5 },
- { id: 'a2', n: 'Weiterer Verwaltungsbereich', d: 'je Bereich zum Anlegen, Bearbeiten, Löschen', h: 1.5, q: true },
- { id: 'a3', n: 'Produktkatalog mit Kategorien', h: 3 },
- { id: 'a4', n: 'Such- und Filterfunktion', h: 2 },
- { id: 'a5', n: 'Auswertungen und Dashboard', h: 3 },
- { id: 'a6', n: 'Import und Export', d: 'CSV oder Excel', h: 1.5 },
- { id: 'a7', n: 'Datei-Upload und Dokumentenablage', h: 1.5 },
- { id: 'a8', n: 'Benachrichtigungen per E-Mail', h: 1.5 },
- ] },
- { g: 'App', items: [
- { id: 'm1', n: 'Web-App', d: 'läuft im Browser, auch auf dem Handy', h: 6 },
- { id: 'm2', n: 'Mobile App zusätzlich', d: 'iOS und Android, inkl. Store-Veröffentlichung', h: 12 },
- { id: 'm3', n: 'Offline-Funktion', h: 4 },
- { id: 'm4', n: 'Push-Benachrichtigungen', h: 2 },
- ] },
- { g: 'Schnittstellen', items: [
- { id: 'i1', n: 'Schnittstelle zu einem Fremdsystem', d: 'Anzahl der Systeme', h: 4, q: true },
- { id: 'i2', n: 'Zahlungsanbieter anbinden', h: 2 },
- { id: 'i3', n: 'Datenübernahme aus einem Altsystem', h: 3 },
- ] },
- ],
- },
- {
- id: 'auto', name: 'Automatisierung und KI', kenso: true,
- hint: 'Jeder Ablauf wird einzeln gezählt — lieber grob schätzen als weglassen.',
- groups: [
- { g: 'Automatisierung', items: [
- { id: 'z1', n: 'Wiederkehrenden Ablauf automatisieren', d: 'je Ablauf, z. B. Terminbestätigung oder Datenweitergabe', h: 3, q: true },
- { id: 'z2', n: 'Automatische Angebots- oder Rechnungserzeugung', h: 3 },
- { id: 'z3', n: 'Dokumente automatisch auslesen', d: 'Lieferscheine, Rechnungen, Formulare', h: 4 },
- { id: 'z4', n: 'KI-Assistent mit eigenem Firmenwissen', d: 'laufende Nutzungskosten trägt der Kunde', h: 6 },
- ] },
- ],
- },
- {
- id: 'run', name: 'Laufende Kosten', kenso: false, monthly: true,
- hint: 'Was nach dem Start jeden Monat läuft.',
- groups: [
- { g: 'Monatlich', items: [
- { id: 'r1', n: 'Wartung und Hosting', d: 'Betrieb, Backups, 1 Stunde Änderungen pro Monat', m: 50 },
- { id: 'r2', n: 'Erhöhter Serverbedarf', d: 'eigene Datenbank oder App-Backend', m: 0, ask: true },
- { id: 'r3', n: 'KI-Nutzung', d: 'ab 50 € je Anliegen pro Monat – je nach Umfang mehr', m: 50, q: true },
- ] },
- ],
- },
-]
-
-// Stundensatz: 100 € für kleine Aufträge, ab 10 Stunden (größeres Projekt) 80 €
-const STAFFEL = [{ max: 9.99, r: 100 }, { max: 1e9, r: 80 }]
-
-const PEOPLE = {
- justin: {
- name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J',
- email: 'justin@webklar.com',
- slots: {
- 1: ['09:00', '10:00', '11:00', '16:00', '17:00'],
- 2: ['09:00', '10:00', '16:00', '17:00'],
- 3: ['09:00', '10:00', '11:00', '16:00'],
- 4: ['09:00', '10:00', '16:00', '17:00'],
- 5: ['09:00', '10:00', '11:00'],
- },
- },
- kenso: {
- name: 'Kenso', role: 'Shop, Software, Automatisierung', initial: 'K',
- email: 'kenso@webklar.com',
- slots: { 2: ['14:00', '15:00', '18:00'], 4: ['14:00', '15:00', '18:00'], 6: ['10:00', '11:00'] },
- },
-}
-
-const ITEM_BY_ID = {}
-for (const cat of CATS) {
- for (const grp of cat.groups) {
- for (const item of grp.items) ITEM_BY_ID[item.id] = { cat, item }
- }
-}
-
-/* ----------------------------------------------------------------- State --- */
-const state = {
- types: { web: true },
- sel: {},
- qty: {},
- wantTermin: false,
- person: 'justin',
- day: '',
- slot: '',
- termNote: '',
- notes: '',
- portalEmail: '',
- portalPass: '',
-}
-
-let customer = null
-let storageKey = 'wk-kalkulation:neu'
-
-/* Schon vergebene Termine (aus den Akquise-Tickets), damit nichts doppelt
- gebucht wird. Wird beim Laden und nach jedem Speichern aktualisiert. */
-let booked = []
-let savedAppointment = null
-
-/* --------------------------------------------------------------- Helfer --- */
-function escapeHtml(str) {
- return String(str ?? '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
-}
-
-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 = `${escapeHtml(t.flagTitle)} ${escapeHtml(t.flagText)}`
-}
-
-function renderTypeChips(t) {
- el('type-chips').innerHTML = CATS.map((cat) => {
- const on = Boolean(state.types[cat.id])
- const count = t.sel.filter((x) => x.cat.id === cat.id).length
- return `
- ${on ? '✓' : ''} ${escapeHtml(cat.name)}${count ? `${count} ` : ''}
- `
- }).join('')
-}
-
-function itemHours(cat, item) {
- const q = item.q ? (state.qty[item.id] || 1) : 1
- if (cat.monthly) return item.ask ? 'n. Last' : `${eur((item.m || 0) * q)}/Mon`
- return `${fmt((item.h || 0) * q)} Std`
-}
-
-function renderItem(cat, item) {
- const on = Boolean(state.sel[item.id])
- const q = state.qty[item.id] || 1
- return `
- ${on ? '✓' : ''}
-
- ${escapeHtml(item.n)}
- ${item.d ? `${escapeHtml(item.d)} ` : ''}
- ${escapeHtml(itemHours(cat, item))}
- ${item.q ? `
- −
-
- +
- ` : ''}
-
-
`
-}
-
-function renderSections() {
- const visible = CATS.filter((cat) => state.types[cat.id])
- el('empty-hint').hidden = visible.length > 0
- el('sections').innerHTML = visible.map((cat) => `
-
-
-
${escapeHtml(cat.name)}
- ${cat.kenso ? 'Kenso gibt frei' : 'darfst du selbst'}
-
- ${escapeHtml(cat.hint)}
- ${cat.groups.map((grp) => `
- ${escapeHtml(grp.g)}
- ${grp.items.map((item) => renderItem(cat, item)).join('')}
- `).join('')}
- `).join('')
-}
-
-function renderChosen(t) {
- el('chosen-panel').hidden = t.sel.length === 0
- el('chosen-label').textContent = `Ausgewählt (${t.sel.length})`
- el('chosen-list').innerHTML = t.sel.map((x) => {
- const value = x.cat.monthly
- ? (x.item.ask ? 'n. Last' : `${eur(x.m)}/M`)
- : `${fmt(x.h)} Std`
- return `${escapeHtml(x.item.n)}${x.q > 1 ? ` ×${x.q}` : ''}${escapeHtml(value)} `
- }).join('')
-}
-
-function renderTermin() {
- el('termin-body').hidden = !state.wantTermin
- document.querySelectorAll('#want-seg button').forEach((btn) => {
- btn.classList.toggle('on', (btn.dataset.want === '1') === state.wantTermin)
- })
- if (!state.wantTermin) return
-
- el('person-list').innerHTML = Object.keys(PEOPLE).map((id) => {
- const p = PEOPLE[id]
- const on = state.person === id
- return `
- ${p.initial}
- ${escapeHtml(p.name)} ${escapeHtml(p.role)}
- `
- }).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 `
- ${d.date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}
- ${d.date.getDate()}.${d.date.getMonth() + 1}.
- ${free ? `${free} frei` : '—'}
- `
- }).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 `${label} `
- }).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 = `Termin steht: ${escapeHtml(line)}${state.termNote ? ` — Thema: ${escapeHtml(state.termNote)}` : ''}`
- } else {
- box.className = 'term-status'
- box.innerHTML = 'Noch kein Termin. Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.'
- }
-}
-
-function render() {
- const t = totals()
- renderNumbers(t)
- renderTypeChips(t)
- renderSections()
- renderChosen(t)
- renderTermin()
- saveState()
-}
-
-/* --------------------------------------------------------------- Aktionen --- */
-function toggleItem(id) {
- if (!ITEM_BY_ID[id]) return
- state.sel[id] = !state.sel[id]
- render()
-}
-
-function changeQty(id, delta) {
- state.qty[id] = Math.max(1, (state.qty[id] || 1) + delta)
- if (!state.sel[id]) state.sel[id] = true
- render()
-}
-
-function setQty(id, value) {
- const n = Math.max(1, parseInt(value, 10) || 1)
- state.qty[id] = n
- render()
-}
-
-/* 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
diff --git a/server/index.js b/server/index.js
index 8a7c77d..85a7e61 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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)
diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js
index a51c862..dddbe6c 100644
--- a/server/middleware/kundenansichtGuard.js
+++ b/server/middleware/kundenansichtGuard.js
@@ -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.' })
}
diff --git a/server/middleware/session.js b/server/middleware/session.js
index 8f0ced1..190fd05 100644
--- a/server/middleware/session.js
+++ b/server/middleware/session.js
@@ -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
}
diff --git a/server/routes/abo.js b/server/routes/abo.js
index e581f12..e32b6fb 100644
--- a/server/routes/abo.js
+++ b/server/routes/abo.js
@@ -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) {
diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js
index 1baf8e0..c6f5b7f 100644
--- a/server/routes/admin/funktionenAdmin.js
+++ b/server/routes/admin/funktionenAdmin.js
@@ -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()
diff --git a/server/routes/admin/switchLink.js b/server/routes/admin/switchLink.js
deleted file mode 100644
index bcd9c9a..0000000
--- a/server/routes/admin/switchLink.js
+++ /dev/null
@@ -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
diff --git a/server/routes/chatAdmin.js b/server/routes/chatAdmin.js
index 54ea328..3715b55 100644
--- a/server/routes/chatAdmin.js
+++ b/server/routes/chatAdmin.js
@@ -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
diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js
index 93cdbc9..bd20cfa 100644
--- a/server/routes/funktionen.js
+++ b/server/routes/funktionen.js
@@ -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),
diff --git a/server/routes/offers.js b/server/routes/offers.js
index 8bda62f..1b84682 100644
--- a/server/routes/offers.js
+++ b/server/routes/offers.js
@@ -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()
diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js
deleted file mode 100644
index 21531c2..0000000
--- a/server/routes/portalAdmin.js
+++ /dev/null
@@ -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
diff --git a/server/routes/storageTasks.js b/server/routes/storageTasks.js
index bb5e431..a9d64cd 100644
--- a/server/routes/storageTasks.js
+++ b/server/routes/storageTasks.js
@@ -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
diff --git a/server/routes/takeoverCredits.js b/server/routes/takeoverCredits.js
index 07f4c90..a97917e 100644
--- a/server/routes/takeoverCredits.js
+++ b/server/routes/takeoverCredits.js
@@ -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()
diff --git a/server/routes/team/index.js b/server/routes/team/index.js
index 06a21d0..2134cac 100644
--- a/server/routes/team/index.js
+++ b/server/routes/team/index.js
@@ -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)
diff --git a/server/routes/team/preview.js b/server/routes/team/preview.js
index c172ad6..63ed4a3 100644
--- a/server/routes/team/preview.js
+++ b/server/routes/team/preview.js
@@ -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()
diff --git a/server/services/appointments.js b/server/services/appointments.js
deleted file mode 100644
index 7068e62..0000000
--- a/server/services/appointments.js
+++ /dev/null
@@ -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
-}
diff --git a/server/services/kundenansicht.js b/server/services/kundenansicht.js
index 34d68c6..56ada69 100644
--- a/server/services/kundenansicht.js
+++ b/server/services/kundenansicht.js
@@ -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
diff --git a/server/services/mailer.js b/server/services/mailer.js
index 86bfb24..22f834e 100644
--- a/server/services/mailer.js
+++ b/server/services/mailer.js
@@ -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 }) {
diff --git a/server/services/offers.js b/server/services/offers.js
index 741b59a..f9ad963 100644
--- a/server/services/offers.js
+++ b/server/services/offers.js
@@ -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) {
diff --git a/server/services/passwordVersion.js b/server/services/passwordVersion.js
index 9e02ff5..79b0166 100644
--- a/server/services/passwordVersion.js
+++ b/server/services/passwordVersion.js
@@ -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) {
diff --git a/server/routes/admin/previewOpen.js b/server/services/staffPreviewLink.js
similarity index 63%
rename from server/routes/admin/previewOpen.js
rename to server/services/staffPreviewLink.js
index 3b18ef3..c9af90d 100644
--- a/server/routes/admin/previewOpen.js
+++ b/server/services/staffPreviewLink.js
@@ -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
diff --git a/server/services/staffPreviewTokens.js b/server/services/staffPreviewTokens.js
index 4c58feb..5f4d39d 100644
--- a/server/services/staffPreviewTokens.js
+++ b/server/services/staffPreviewTokens.js
@@ -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()
diff --git a/test/aboRoutes.test.js b/test/aboRoutes.test.js
index be9986c..54abb6c 100644
--- a/test/aboRoutes.test.js
+++ b/test/aboRoutes.test.js
@@ -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)
})
diff --git a/test/adminPanelGone.test.js b/test/adminPanelGone.test.js
new file mode 100644
index 0000000..fe2de12
--- /dev/null
+++ b/test/adminPanelGone.test.js
@@ -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`)
+ }
+})
diff --git a/test/customerPassword.test.js b/test/customerPassword.test.js
index 32dd8ab..16ffb1c 100644
--- a/test/customerPassword.test.js
+++ b/test/customerPassword.test.js
@@ -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', '')
diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js
index 96e7002..cb23ab0 100644
--- a/test/funktionenRoutes.test.js
+++ b/test/funktionenRoutes.test.js
@@ -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()
diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js
index af510b0..30bb54f 100644
--- a/test/kundenansichtGuard.test.js
+++ b/test/kundenansichtGuard.test.js
@@ -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 })
- assert.equal(res.status, 409)
- assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
- }
+ 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)
})
diff --git a/test/mailStorage.test.js b/test/mailStorage.test.js
index 92b2999..9be231d 100644
--- a/test/mailStorage.test.js
+++ b/test/mailStorage.test.js
@@ -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)
})
diff --git a/test/newsRoutes.test.js b/test/newsRoutes.test.js
index eda8bbc..ae6da04 100644
--- a/test/newsRoutes.test.js
+++ b/test/newsRoutes.test.js
@@ -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 () => {
diff --git a/test/planSwitch.test.js b/test/planSwitch.test.js
index 4ef2bb2..27c741e 100644
--- a/test/planSwitch.test.js
+++ b/test/planSwitch.test.js
@@ -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)
})
diff --git a/test/staffSwitch.test.js b/test/staffSwitch.test.js
deleted file mode 100644
index 86d9ce7..0000000
--- a/test/staffSwitch.test.js
+++ /dev/null
@@ -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, /← Ticketsystem<\/a>/)
-})
diff --git a/test/storageTasks.test.js b/test/storageTasks.test.js
index 23279a8..f33047c 100644
--- a/test/storageTasks.test.js
+++ b/test/storageTasks.test.js
@@ -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')
})
diff --git a/test/support/fixtures.js b/test/support/fixtures.js
index b59ccaf..e002833 100644
--- a/test/support/fixtures.js
+++ b/test/support/fixtures.js
@@ -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',
diff --git a/test/support/testApp.js b/test/support/testApp.js
index 526ee17..c0ce0f1 100644
--- a/test/support/testApp.js
+++ b/test/support/testApp.js
@@ -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
diff --git a/test/teamChat.test.js b/test/teamChat.test.js
index 2bbbaa0..93c1ff7 100644
--- a/test/teamChat.test.js
+++ b/test/teamChat.test.js
@@ -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)
diff --git a/test/ui/admin-funktionen.ui.mjs b/test/ui/admin-funktionen.ui.mjs
deleted file mode 100644
index 2e65960..0000000
--- a/test/ui/admin-funktionen.ui.mjs
+++ /dev/null
@@ -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)