diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index 24aee32..df03618 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -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: ,
@@ -31,6 +34,9 @@ const ICONS = {
services: ,
customers: ,
chat: ,
+ gutschriften: ,
+ angebote: ,
+ speicher: ,
leads: ,
finance: ,
legal: ,
@@ -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]}
{child.label}
diff --git a/src/hooks/useChatUnread.js b/src/hooks/useChatUnread.js
deleted file mode 100644
index b250333..0000000
--- a/src/hooks/useChatUnread.js
+++ /dev/null
@@ -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
-}
diff --git a/src/hooks/useCustomerAbo.js b/src/hooks/useCustomerAbo.js
index 2762d8a..fda86a0 100644
--- a/src/hooks/useCustomerAbo.js
+++ b/src/hooks/useCustomerAbo.js
@@ -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()
diff --git a/src/hooks/useSidebarCounts.js b/src/hooks/useSidebarCounts.js
new file mode 100644
index 0000000..52def09
--- /dev/null
+++ b/src/hooks/useSidebarCounts.js
@@ -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
+}
diff --git a/src/lib/creditFormat.js b/src/lib/creditFormat.js
new file mode 100644
index 0000000..f91c7c5
--- /dev/null
+++ b/src/lib/creditFormat.js
@@ -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`
+}
diff --git a/src/lib/customerAdminApi.js b/src/lib/customerAdminApi.js
index b59a469..fc76d21 100644
--- a/src/lib/customerAdminApi.js
+++ b/src/lib/customerAdminApi.js
@@ -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' })
-}
diff --git a/src/lib/format.js b/src/lib/format.js
new file mode 100644
index 0000000..2423379
--- /dev/null
+++ b/src/lib/format.js
@@ -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')
diff --git a/src/lib/navLinks.js b/src/lib/navLinks.js
index 7a6c2de..7f77d2c 100644
--- a/src/lib/navLinks.js
+++ b/src/lib/navLinks.js
@@ -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 },
diff --git a/src/lib/offerFormat.js b/src/lib/offerFormat.js
new file mode 100644
index 0000000..8267dcb
--- /dev/null
+++ b/src/lib/offerFormat.js
@@ -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(' ')
+}
diff --git a/src/lib/storageFormat.js b/src/lib/storageFormat.js
new file mode 100644
index 0000000..0da1915
--- /dev/null
+++ b/src/lib/storageFormat.js
@@ -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}` : ''}`
diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js
index 2d1f9c8..d206ab0 100644
--- a/src/lib/teamApi.js
+++ b/src/lib/teamApi.js
@@ -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`)
diff --git a/tests/unit/adminFormat.test.mjs b/tests/unit/adminFormat.test.mjs
new file mode 100644
index 0000000..878952c
--- /dev/null
+++ b/tests/unit/adminFormat.test.mjs
@@ -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.')
+})
diff --git a/tests/unit/navLinks.test.mjs b/tests/unit/navLinks.test.mjs
index 635f63f..6f03916 100644
--- a/tests/unit/navLinks.test.mjs
+++ b/tests/unit/navLinks.test.mjs
@@ -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')
})