Team-API, Sidebar-Zaehler und Format-Hilfen fuer Gutschriften, Angebote und E-Mail-Speicher

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 02:55:48 +00:00
1 parent 813c6a7e18
commit 91d526e909
13 files changed
+487 -47

No files matched your search

+10 -4
View File
@@ -17,12 +17,15 @@ import {
IconLayoutSidebarLeftExpand,
IconExternalLink,
IconMessages,
IconReceiptRefund,
IconDiscount,
IconMailCog,
} from '@tabler/icons-react'
import { openProjectAdmin } from '../lib/employeeAdminApi'
import { serverpulsApi } from '../lib/serverpulsApi'
import { startsCollapsed } from '../lib/sidebar'
import { buildNavLinks } from '../lib/navLinks'
import { useChatUnread } from '../hooks/useChatUnread'
import { useSidebarCounts } from '../hooks/useSidebarCounts'
const ICONS = {
tickets: <IconTicket />,
@@ -31,6 +34,9 @@ const ICONS = {
services: <IconLayoutGrid />,
customers: <IconUsers />,
chat: <IconMessages />,
gutschriften: <IconReceiptRefund />,
angebote: <IconDiscount />,
speicher: <IconMailCog />,
leads: <IconTargetArrow />,
finance: <IconReportMoney />,
legal: <IconScale />,
@@ -45,7 +51,7 @@ export default function Sidebar() {
const [collapsed, setCollapsed] = useState(() => startsCollapsed())
const [switching, setSwitching] = useState(false)
const [pulseLevel, setPulseLevel] = useState('ok')
const chatUnread = useChatUnread()
const counts = useSidebarCounts()
// Serverpuls-Punkt: nur Admins, beim Laden und alle 5 Minuten; Fehler bleiben still
useEffect(() => {
@@ -72,7 +78,7 @@ export default function Sidebar() {
}
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js, Icons ueber key
const links = buildNavLinks({ isAdmin, pulseLevel, chatUnread })
const links = buildNavLinks({ isAdmin, pulseLevel, counts })
const isActive = (href) =>
location.pathname === href || location.pathname.startsWith(href + '/')
@@ -125,7 +131,7 @@ export default function Sidebar() {
key={child.href}
to={child.href}
className={`side-link side-sublink ${isActive(child.href) ? 'active' : ''}`}
title={child.count > 0 ? `${child.label} (${child.count} ungelesen)` : child.label}
title={child.count > 0 ? `${child.label} (${child.count} ${child.key === 'chat' ? 'ungelesen' : 'offen'})` : child.label}
>
{ICONS[child.key]}
<span className="side-link-label">{child.label}</span>
-31
View File
@@ -1,31 +0,0 @@
import { useEffect, useState } from 'react'
import { getChatUnreadCount } from '../lib/teamApi'
// Zahl ungelesener Kundennachrichten fuer die Sidebar: jede Minute bei sichtbarem Tab und sofort beim
// Zurueckkehren; Fehler behalten den letzten Wert.
const POLL_MS = 60 * 1000
export function useChatUnread() {
const [unread, setUnread] = useState(0)
useEffect(() => {
let dead = false
const load = () => {
if (document.visibilityState === 'hidden') return
getChatUnreadCount()
.then((r) => { if (!dead) setUnread(Number(r?.unread) || 0) })
.catch(() => {})
}
load()
const iv = setInterval(load, POLL_MS)
const onVisible = () => { if (document.visibilityState === 'visible') load() }
document.addEventListener('visibilitychange', onVisible)
return () => {
dead = true
clearInterval(iv)
document.removeEventListener('visibilitychange', onVisible)
}
}, [])
return unread
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { getCustomerAbo } from '../lib/customerAdminApi'
// Team-Eingang: alle Mitarbeiter (vorher Admin-Route, Nicht-Admins bekamen 403 und damit immer den Schalter)
import { getCustomerAboStatus as getCustomerAbo } from '../lib/teamApi'
// Ergebnis je Kunde zwischengespeichert (nur Erfolge), laufende Abfragen werden geteilt
const cache = new Map()
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
import { getChatUnreadCount, getCreditOpenCount, getStorageOpenCount } from '../lib/teamApi'
// Zahlen in der Sidebar unter Kunden: ungelesene Chats, offene Gutschrift-Antraege, offene Speicher-Aufgaben.
// Jede Minute bei sichtbarem Tab und sofort beim Zurueckkehren; Fehler behalten den letzten Wert.
const POLL_MS = 60 * 1000
const SOURCES = [
['chat', () => getChatUnreadCount().then((r) => r?.unread)],
['credits', () => getCreditOpenCount().then((r) => r?.open)],
['storage', () => getStorageOpenCount().then((r) => r?.open)],
]
export function useSidebarCounts() {
const [counts, setCounts] = useState({ chat: 0, credits: 0, storage: 0 })
useEffect(() => {
let dead = false
const load = () => {
if (document.visibilityState === 'hidden') return
for (const [key, fetchCount] of SOURCES) {
fetchCount()
.then((n) => { if (!dead) setCounts((c) => ({ ...c, [key]: Number(n) || 0 })) })
.catch(() => {})
}
}
load()
const iv = setInterval(load, POLL_MS)
const onVisible = () => { if (document.visibilityState === 'visible') load() }
document.addEventListener('visibilitychange', onVisible)
return () => {
dead = true
clearInterval(iv)
document.removeEventListener('visibilitychange', onVisible)
}
}, [])
return counts
}
+64
View File
@@ -0,0 +1,64 @@
import { fmtDate, formatCents } from './format'
// Uebernahmegutschriften: Texte und Pruefungen wortgleich aus dem Admin-Panel (public/admin.js 535-906).
export const CREDIT_STATUS = {
offen: { tone: 'warn', label: 'Zu prüfen' },
rueckfrage: { tone: 'muted', label: 'Rückfrage beim Kunden' },
genehmigt: { tone: 'ok', label: 'Genehmigt' },
abgelehnt: { tone: 'danger', label: 'Abgelehnt' },
}
export const creditStatus = (status) => CREDIT_STATUS[status] || { tone: 'muted', label: status }
const 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',
}
export const historyLabel = (type) => HISTORY_LABELS[type] || type
export const BILLING_MONTHS = [1, 3, 6, 12, 24, 36]
export function calcLine(calc) {
return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` +
`Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${formatCents(calc.suggestedCents)}`
}
/** Fehlertext vor dem Absenden (Rueckfrage/Ablehnung brauchen eine Nachricht ab 5 Zeichen) oder null */
export function decisionError(action, note) {
if (action === 'approve' || String(note || '').trim().length >= 5) return null
return action === 'question'
? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.'
: 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.'
}
export function decisionConfirm(action, amountCents, customerName) {
const who = customerName || 'den Kunden'
return {
approve: `Gutschrift über ${formatCents(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]
}
export function decisionMessage(action, result) {
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.`)
return parts.join(' ')
}
export const applyMessage = (result) => (result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`)
export 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`
}
-5
View File
@@ -34,8 +34,3 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
export async function deleteCustomerWithPortalAccess(customerId) {
return adminFetch(`/api/admin/customers/${customerId}`, { method: 'DELETE' })
}
// Wartungs-/Hosting-Abo des Kunden: { productKey: 'wartung' | 'hosting' | null, active }
export async function getCustomerAbo(customerId) {
return adminFetch(`/api/admin/customers/${encodeURIComponent(customerId)}/abo`, { method: 'GET' })
}
+49
View File
@@ -0,0 +1,49 @@
// Gemeinsame Formate fuer die Bereiche aus dem Admin-Panel (Gutschriften, Angebote, E-Mail-Speicher).
// Deutsche Zeit (Europe/Berlin), Euro wie Intl de-DE. Rein, unter Node testbar.
const EURO = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
/** 123456 -> "1.234,56 €" (mit geschuetztem Leerzeichen wie Intl) */
export const formatCents = (cents) => EURO.format((Number(cents) || 0) / 100)
/** "12,5" / "12.50" -> 1250; leer oder ungueltig -> null */
export function parseEuroToCents(text) {
const s = String(text ?? '').trim().replace(/\s|€/g, '')
if (!s) return null
const normal = s.includes(',') ? s.replace(/\./g, '').replace(',', '.') : s
if (!/^-?\d+(\.\d{1,2})?$/.test(normal)) return null
return Math.round(Number(normal) * 100)
}
const parts = (value, options) =>
Object.fromEntries(
new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hourCycle: 'h23', ...options })
.formatToParts(new Date(value))
.map((p) => [p.type, p.value])
)
const valid = (value) => value && !Number.isNaN(new Date(value).getTime())
/** dd.mm.yyyy (deutsche Zeit); leer -> "–" */
export function fmtDate(value) {
if (!valid(value)) return value ? String(value) : '–'
const p = parts(value, { day: '2-digit', month: '2-digit', year: 'numeric' })
return `${p.day}.${p.month}.${p.year}`
}
/** dd.mm.yy, hh:mm (deutsche Zeit) wie im Admin-Panel; leer -> "–" */
export function fmtDateTime(value) {
if (!valid(value)) return value ? String(value) : '–'
const p = parts(value, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute}`
}
/** Datumsfeld "2026-10-27" -> "27.10.2026" ohne Zeitzonen-Umweg */
export const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.')
/** Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD) */
export const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
export const fmtBerlin = (value) => fmtYmd(berlinYmd(value))
export const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
export const productLabelOf = (key) => PRODUCT_LABEL[key] || (key ? String(key) : 'Abo')
+8 -2
View File
@@ -1,7 +1,7 @@
// Eintraege der Sidebar als reine Daten (unter Node testbar); Navbar.jsx ordnet die Icons ueber key zu.
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0).
export function buildNavLinks({ isAdmin, pulseLevel, chatUnread }) {
export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
return [
{ key: 'tickets', label: 'Tickets', href: '/tickets' },
{ key: 'planboard', label: 'Planboard', href: '/planboard' },
@@ -11,7 +11,13 @@ export function buildNavLinks({ isAdmin, pulseLevel, chatUnread }) {
key: 'customers',
label: 'Kunden',
href: '/customers',
children: [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: chatUnread }],
// Bereiche aus dem frueheren Admin-Panel; Zahlen: ungelesene Chats, offene Gutschriften, offene Speicher-Aufgaben
children: [
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0 },
{ key: 'gutschriften', label: 'Gutschriften', href: '/customers/gutschriften', count: counts.credits ?? 0 },
{ key: 'angebote', label: 'Abo-Angebote', href: '/customers/angebote' },
{ key: 'speicher', label: 'E-Mail-Speicher', href: '/customers/email-speicher', count: counts.storage ?? 0 },
],
},
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ key: 'leads', label: 'Leads', href: '/leads', paused: true },
+110
View File
@@ -0,0 +1,110 @@
import { berlinYmd, fmtBerlin, fmtDate, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
// Abo-Angebote: Texte, Rabattende und Formularpruefung wortgleich aus dem Admin-Panel (public/admin.js 984-1317).
// "today" ist der Kalendertag in Deutschland (YYYY-MM-DD) und fuer Tests injizierbar.
export const OFFER_STATUS = {
offen: { tone: 'warn', label: 'offen' },
aktiv: { tone: 'ok', label: 'aktiv' },
abgelaufen: { tone: 'muted', label: 'abgelaufen' },
zurueckgezogen: { tone: 'muted', label: 'zurückgezogen' },
}
export const offerStatus = (status) => OFFER_STATUS[status] || { tone: 'muted', label: status }
const todayBerlin = () => berlinYmd(new Date())
export function offerPreviewText(price, months, regular, running) {
const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate`
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${formatCents(price)} statt ${formatCents(regular)}`
return `${lead} ${amount}, danach ${formatCents(regular)} pro Monat`
}
/** Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.) */
export 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 eingeloesten Angebots (Abschluss heute bis letzter Gueltigkeitstag) */
export function offerDiscountEstimate(months, validUntilYmd, testAccount = false, today = todayBerlin()) {
const n = months + (testAccount ? 1 : 0)
return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) }
}
export function discountLine(o, today = todayBerlin()) {
if (o.status === 'offen' && o.validUntil) {
const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount, today)
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)}`
}
export const isRunning = (ctx, productKey) => Boolean(ctx?.subscription?.active && ctx.subscription.productKey === productKey)
/**
* Pruefung und Vorschau des Formulars "Neues Angebot" (admin.js renderOfferPreview).
* -> { errors, running, saveLabel, previewLines }; Speichern nur ohne errors.
*/
export function evaluateOfferForm(ctx, f, today = todayBerlin()) {
const regular = ctx.products[f.productKey].regularPriceCents
const running = isRunning(ctx, f.productKey)
const openOffer = ctx.openOffers[f.productKey]
const errors = []
if (openOffer) errors.push(`Es gibt schon ein offenes ${PRODUCT_LABEL[f.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
if (!Number.isInteger(f.monthlyPriceCents) || f.monthlyPriceCents < 0 || f.monthlyPriceCents >= regular) {
errors.push(`Angebotspreis bitte zwischen 0,00 € und ${formatCents(regular - 1)} angeben.`)
}
if (!f.validUntil && !running) errors.push('Bitte „Gültig bis“ angeben.')
const previewLines = []
if (!errors.length) {
previewLines.push(`${offerPreviewText(f.monthlyPriceCents, f.months, regular, running)} · Ersparnis ${formatCents((regular - f.monthlyPriceCents) * f.months)}`)
if (running) {
const charges = ctx.subscription.upcomingCharges || []
if (charges.length > f.months) previewLines.push(`Rabatt endet am ${fmtBerlin(charges[f.months])} (nach ${f.months === 1 ? '1 Abbuchung' : `${f.months} Abbuchungen`}).`)
} else {
previewLines.push(`Angebot gültig bis ${fmtYmd(f.validUntil)}`)
const { earliest, latest } = offerDiscountEstimate(f.months, f.validUntil, ctx.customer.testAccount, today)
const lead = `Rabatt läuft ${f.months === 1 ? '1 Monat' : `${f.months} Monate`} ab Abschluss und endet voraussichtlich`
previewLines.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.`)
}
}
previewLines.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) previewLines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
if (ctx.customer.testAccount) previewLines.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'}.`)
return { errors, running, saveLabel: running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen', previewLines }
}
export function offerConfirm(ctx, f, running) {
const text = offerPreviewText(f.monthlyPriceCents, f.months, ctx.products[f.productKey].regularPriceCents, running)
return `Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(f.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`
}
export function createdMessage(result, notifyCustomer) {
const o = result.offer
const until = o.scope !== 'laufend'
? `, gültig bis ${fmtDate(o.validUntil)}`
: o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : ''
return [
`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' : ''}.`,
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(' ')
}
+39
View File
@@ -0,0 +1,39 @@
import { fmtDate } from './format'
// E-Mail-Speicher (Zusatzspeicher-Aufgaben in konsoleH): Texte wortgleich aus dem Admin-Panel (public/admin.js 908-982).
const GB_INCLUDED = 5
const GB_PER_UNIT = 5
const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0)
export 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)`
}
export function summaryText({ allocatedGb, packageGb, warnGb, packageName }) {
const warn = Number(allocatedGb) >= Number(warnGb)
const head = `${allocatedGb} von ${packageGb} GB vergeben (Paket ${packageName})`
return { warn, text: warn ? `${head} – Warnschwelle ${warnGb} GB erreicht: größeres Hetzner-Paket buchen.` : `${head} · Warnung ab ${warnGb} GB` }
}
export const taskText = (t) => (t.kind === 'einrichten'
? `Postfachgrenze in konsoleH jetzt auf ${t.targetGb} GB setzen`
: `Postfachgrenze in konsoleH am ${fmtDate(t.dueAt)} auf ${t.targetGb} GB senken`)
export const scopeText = (t) =>
`Gesamtspeicher des Kunden: ${t.targetGb} GB, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${t.contactDomain}` : ''}`
export const changeText = (t) => `${storageLabel(t.fromUnits)} → ${storageLabel(t.toUnits)}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}`
export const partialText = (t) => (t.partial
? `Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${t.toUnits} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${t.error}` : ''}`
: '')
export const orderedKind = (t) => (t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')
export const isOverdue = (t, now = Date.now()) => Boolean(t.dueAt && new Date(t.dueAt).getTime() <= now)
export const failedText = (f) =>
`${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}`
+40
View File
@@ -59,3 +59,43 @@ export const chatUploadUrl = (customerId) => `${PORTAL_URL}/api/team/chats/${enc
export async function teamAuthHeaders() {
return { Authorization: `Bearer ${await jwtCache.get()}` }
}
// --- Gutschriften (Uebernahmegutschriften) ---
const query = (params) => {
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString()
return q ? `?${q}` : ''
}
/** { requests, openCount }; status 'open' (Standard) oder 'all', optional nur ein Kunde */
export const listCredits = ({ status = 'open', customerId } = {}) => request(`/takeover-credits${query({ status, customerId })}`)
export const getCreditOpenCount = () => request('/takeover-credits/open-count')
export const getCredit = (id) => request(`/takeover-credits/${enc(id)}`)
export const calcCredit = (body) => request('/takeover-credits/calc', { method: 'POST', body })
export const decideCredit = (id, body) => request(`/takeover-credits/${enc(id)}/decision`, { method: 'POST', body })
export const applyCredit = (id) => request(`/takeover-credits/${enc(id)}/apply`, { method: 'POST', body: {} })
// --- Abo-Angebote ---
export const listOffers = ({ customerId } = {}) => request(`/offers${query({ customerId })}`)
export const getOfferContext = (customerId) => request(`/offers/context/${enc(customerId)}`)
export const createOffer = (body) => request('/offers', { method: 'POST', body })
export const withdrawOffer = (id) => request(`/offers/${enc(id)}/withdraw`, { method: 'POST', body: {} })
export const notifyOffer = (id) => request(`/offers/${enc(id)}/notify`, { method: 'POST', body: {} })
// --- E-Mail-Speicher ---
export const listStorageTasks = () => request('/storage-tasks')
export const getStorageOpenCount = () => request('/storage-tasks/open-count')
export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`, { method: 'POST', body: {} })
// --- Funktionen (News-Pinnwand je Projekt) ---
export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`)
export const setNewsFeature = (customerId, { projectId, action, note }) =>
request(`/funktionen/${enc(customerId)}/news`, { method: 'POST', body: { projectId, action, note } })
// --- Abo-Status (alle Mitarbeiter) ---
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
export const getCustomerAboStatus = (customerId) => request(`/customers/${enc(customerId)}/abo`)
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Etappe 3: Texte und Rechnungen aus dem Admin-Panel (public/admin.js), wortgleich ins Ticketsystem uebernommen
const fmt = await loadModule('src/lib/format.js')
const credit = await loadModule('src/lib/creditFormat.js')
const offer = await loadModule('src/lib/offerFormat.js')
const storage = await loadModule('src/lib/storageFormat.js')
const NB = ' '
test('format: Euro aus Cent, Eingabe in Cent, deutsche Daten', () => {
assert.equal(fmt.formatCents(123456), `1.234,56${NB}€`)
assert.equal(fmt.formatCents(0), `0,00${NB}€`)
assert.equal(fmt.parseEuroToCents('12,5'), 1250)
assert.equal(fmt.parseEuroToCents('12.50'), 1250)
assert.equal(fmt.parseEuroToCents(''), null)
assert.equal(fmt.parseEuroToCents('abc'), null)
assert.equal(fmt.fmtDate('2026-09-30T22:30:00.000Z'), '01.10.2026')
assert.equal(fmt.fmtDate(null), '–')
assert.equal(fmt.fmtDateTime('2026-09-30T10:05:00.000Z'), '30.09.26, 12:05')
assert.equal(fmt.fmtYmd('2026-10-27'), '27.10.2026')
})
test('Gutschrift: Status, Verlauf, Rechenzeile', () => {
assert.deepEqual(credit.CREDIT_STATUS.offen, { tone: 'warn', label: 'Zu prüfen' })
assert.deepEqual(credit.CREDIT_STATUS.rueckfrage, { tone: 'muted', label: 'Rückfrage beim Kunden' })
assert.equal(credit.historyLabel('verrechnet'), 'In Stripe gutgeschrieben')
assert.equal(credit.historyLabel('nachweise-geloescht'), 'Nachweise gelöscht')
assert.equal(credit.historyLabel('neu'), 'neu')
assert.deepEqual(credit.BILLING_MONTHS, [1, 3, 6, 12, 24, 36])
assert.equal(
credit.calcLine({ periodStart: '2026-01-01', periodEnd: '2026-12-31', periodDays: 365, overlapStart: '2026-10-01', overlapDays: 92, suggestedCents: 3025 }),
`Rechnungszeitraum 01.01.2026 – 31.12.2026 (365 Tage). Doppelt bezahlt ab 01.10.2026: 92 Tage → Vorschlag 30,25${NB}€`
)
})
test('Gutschrift: Pruefung, Rueckfragen und Meldungen wie im Admin-Panel', () => {
assert.equal(credit.decisionError('question', 'kurz'), 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.')
assert.equal(credit.decisionError('reject', ''), 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.')
assert.equal(credit.decisionError('question', 'Bitte Rechnung'), null)
assert.equal(credit.decisionError('approve', ''), null)
assert.equal(credit.decisionConfirm('approve', 3025, 'Berta'), `Gutschrift über 30,25${NB}€ genehmigen, in Stripe gutschreiben und Berta per E-Mail informieren?`)
assert.equal(credit.decisionConfirm('question', 0, ''), 'Rückfrage per E-Mail an den Kunden senden?')
assert.equal(credit.decisionConfirm('reject', 0, 'Berta'), 'Antrag ablehnen und Berta die Begründung per E-Mail senden?')
assert.equal(credit.decisionMessage('approve', { status: 'genehmigt', applied: true, mailSent: true }), 'Genehmigt. Das Guthaben ist in Stripe gebucht. Die E-Mail an den Kunden ist raus.')
assert.equal(
credit.decisionMessage('approve', { status: 'genehmigt', applied: false, reason: 'Stripe: kaputt', mailSent: false, mailError: 'SMTP weg' }),
'Genehmigt. Noch nicht in Stripe gebucht: Stripe: kaputt E-Mail an den Kunden fehlgeschlagen (SMTP weg) – bitte selbst informieren.'
)
assert.equal(credit.decisionMessage('question', { status: 'rueckfrage', mailSent: false }), 'Rückfrage gesendet. E-Mail an den Kunden fehlgeschlagen (unbekannt) – bitte selbst informieren.')
assert.equal(credit.applyMessage({ applied: true }), 'Das Guthaben ist in Stripe gebucht.')
assert.equal(credit.applyMessage({ applied: false, reason: 'Der Kunde hat kein laufendes Abo.' }), 'Nicht gebucht: Der Kunde hat kein laufendes Abo.')
})
test('Angebot: Vorschautext, Monate, Rabattende', () => {
assert.equal(offer.offerPreviewText(2500, 2, 5000, false), `Erste 2 Monate je 25,00${NB}€ statt 50,00${NB}€, danach 50,00${NB}€ pro Monat`)
assert.equal(offer.offerPreviewText(0, 1, 2000, true), `Nächster Monat kostenlos, danach 20,00${NB}€ pro Monat`)
assert.equal(offer.addMonthsYmd('2026-01-31', 1), '2026-02-28')
assert.equal(offer.addMonthsYmd('2026-11-15', 2), '2027-01-15')
const est = offer.offerDiscountEstimate(2, '2026-10-31', false, '2026-10-02')
assert.deepEqual(est, { earliest: '2026-12-02', latest: '2026-12-31' })
assert.deepEqual(offer.offerDiscountEstimate(2, '2026-10-31', true, '2026-10-02'), { earliest: '2027-01-02', latest: '2027-01-31' })
assert.equal(offer.discountLine({ status: 'offen', validUntil: '2026-10-02T00:00:00.000Z', months: 2 }, '2026-10-02'), 'Rabatt endet voraussichtlich am 02.12.2026')
assert.equal(offer.discountLine({ status: 'aktiv', discount: { known: false } }), 'Rabattende unbekannt (Abo nicht gefunden)')
assert.equal(offer.discountLine({ status: 'aktiv', discount: { known: true, endsAt: '2099-01-01T12:00:00.000Z' } }), 'Rabatt endet am 01.01.2099')
assert.deepEqual(offer.OFFER_STATUS.zurueckgezogen, { tone: 'muted', label: 'zurückgezogen' })
})
const ctx = (over = {}) => ({
customer: { id: 'c1', name: 'Berta', portalAccessEnabled: true, testAccount: false },
subscription: null,
openOffers: { hosting: null, wartung: null },
products: { hosting: { regularPriceCents: 2000 }, wartung: { regularPriceCents: 5000 } },
defaultValidUntil: '2026-11-01',
...over,
})
const form = (over = {}) => ({ customerId: 'c1', productKey: 'wartung', monthlyPriceCents: 2500, months: 2, validUntil: '2026-10-31', ...over })
test('Angebotsformular: Sperren, laufendes Abo, Knopftext', () => {
const ok = offer.evaluateOfferForm(ctx(), form(), '2026-10-02')
assert.deepEqual(ok.errors, [])
assert.equal(ok.running, false)
assert.equal(ok.saveLabel, 'Angebot anlegen')
assert.equal(ok.previewLines[0], `Erste 2 Monate je 25,00${NB}€ statt 50,00${NB}€, danach 50,00${NB}€ pro Monat · Ersparnis 50,00${NB}€`)
assert.equal(ok.previewLines.at(-1), 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
const blocked = offer.evaluateOfferForm(ctx({ openOffers: { hosting: null, wartung: { ref: 'A1', text: 'x' } } }), form({ monthlyPriceCents: 5000 }), '2026-10-02')
assert.deepEqual(blocked.errors, [
'Es gibt schon ein offenes Wartungs-Abo-Angebot (A1: x) – bitte zuerst zurückziehen.',
`Angebotspreis bitte zwischen 0,00 € und 49,99${NB}€ angeben.`,
])
const running = offer.evaluateOfferForm(ctx({ subscription: { active: true, productKey: 'wartung', currentPeriodEnd: '2026-11-01T00:00:00.000Z', upcomingCharges: ['2026-11-01T00:00:00.000Z', '2026-12-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'] } }), form({ validUntil: '' }), '2026-10-02')
assert.equal(running.running, true)
assert.equal(running.saveLabel, 'Angebot anlegen und aufs laufende Abo anwenden')
assert.ok(running.previewLines.includes('Rabatt endet am 01.01.2027 (nach 2 Abbuchungen).'))
assert.deepEqual(offer.evaluateOfferForm(ctx(), form({ validUntil: '' }), '2026-10-02').errors, ['Bitte „Gültig bis“ angeben.'])
assert.equal(
offer.offerConfirm(ctx(), form(), false),
`Angebot für Berta anlegen?\n\nErste 2 Monate je 25,00${NB}€ statt 50,00${NB}€, danach 50,00${NB}€ pro Monat\nGültig bis 31.10.2026\n\nDer Coupon wird sofort in Stripe angelegt.`
)
})
test('E-Mail-Speicher: Pakete, Zusammenfassung, Aufgaben', () => {
assert.equal(storage.storageLabel(0), 'kein Paket (5 GB)')
assert.equal(storage.storageLabel(1), '1 Paket (10 GB)')
assert.equal(storage.storageLabel(3), '3 Pakete (20 GB)')
assert.deepEqual(storage.summaryText({ allocatedGb: 20, packageGb: 50, warnGb: 40, packageName: 'Webhosting M' }), { warn: false, text: '20 von 50 GB vergeben (Paket Webhosting M) · Warnung ab 40 GB' })
assert.deepEqual(storage.summaryText({ allocatedGb: 41, packageGb: 50, warnGb: 40, packageName: 'Webhosting M' }), { warn: true, text: '41 von 50 GB vergeben (Paket Webhosting M) – Warnschwelle 40 GB erreicht: größeres Hetzner-Paket buchen.' })
assert.equal(storage.taskText({ kind: 'einrichten', targetGb: 15 }), 'Postfachgrenze in konsoleH jetzt auf 15 GB setzen')
assert.equal(storage.taskText({ kind: 'periodenende', targetGb: 10, dueAt: '2026-11-01T00:00:00.000Z' }), 'Postfachgrenze in konsoleH am 01.11.2026 auf 10 GB senken')
assert.equal(storage.scopeText({ targetGb: 15, contactDomain: 'kunde.de' }), 'Gesamtspeicher des Kunden: 15 GB, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4 · Kontakt: kunde.de')
assert.equal(storage.changeText({ fromUnits: 0, toUnits: 2, viaCancellation: true }), 'kein Paket (5 GB) → 2 Pakete (15 GB) · über die Kündigungsseite')
assert.equal(storage.partialText({ partial: true, toUnits: 1, error: 'x' }), 'Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf 1 Paket(en) – Stückzahl in Stripe von Hand nachziehen. Fehler: x')
assert.equal(storage.orderedKind({ fromUnits: 0, toUnits: 2 }), 'Bestellung (Plus)')
assert.equal(storage.orderedKind({ fromUnits: 3, toUnits: 1 }), 'Verringerung (Minus)')
assert.equal(storage.failedText({ fromUnits: 0, toUnits: 1, error: '' }), 'kein Paket (5 GB) → 1 Paket (10 GB) – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.')
})
+10 -4
View File
@@ -5,15 +5,21 @@ import { loadModule } from './load.mjs'
// Sidebar: Eintraege als reine Daten (Icons ordnet Navbar.jsx ueber key zu)
const { buildNavLinks } = await loadModule('src/lib/navLinks.js')
test('buildNavLinks: Chat unter Kunden mit Zahl, Admin-Eintraege nur fuer Admins', () => {
const links = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', chatUnread: 3 })
test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen, Admin-Eintraege nur fuer Admins', () => {
const links = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', counts: { chat: 3, credits: 1, storage: 2 } })
const kunden = links.find((l) => l.key === 'customers')
assert.equal(kunden.href, '/customers')
assert.deepEqual(kunden.children, [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: 3 }])
assert.deepEqual(kunden.children.map((c) => [c.key, c.label, c.href, c.count]), [
['chat', 'Chat', '/customers/chat', 3],
['gutschriften', 'Gutschriften', '/customers/gutschriften', 1],
['angebote', 'Abo-Angebote', '/customers/angebote', undefined],
['speicher', 'E-Mail-Speicher', '/customers/email-speicher', 2],
])
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal'])
assert.equal(links.find((l) => l.key === 'leads').paused, true)
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', chatUnread: 0 })
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', counts: {} })
assert.deepEqual(admin.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'finance', 'legal', 'serverpuls', 'admin'])
assert.equal(admin.find((l) => l.key === 'serverpuls').dot, 'warn')
assert.equal(admin.find((l) => l.key === 'finance').also, '/finanzen')
assert.equal(admin.find((l) => l.key === 'customers').children[0].count, 0, 'ohne Zahl 0')
})