From 91d526e90914a61817556e199c3b496e085481e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:55:48 +0000 Subject: [PATCH] Team-API, Sidebar-Zaehler und Format-Hilfen fuer Gutschriften, Angebote und E-Mail-Speicher Co-Authored-By: Claude Opus 5.5 --- src/components/Navbar.jsx | 14 ++-- src/hooks/useChatUnread.js | 31 --------- src/hooks/useCustomerAbo.js | 3 +- src/hooks/useSidebarCounts.js | 38 +++++++++++ src/lib/creditFormat.js | 64 +++++++++++++++++ src/lib/customerAdminApi.js | 5 -- src/lib/format.js | 49 +++++++++++++ src/lib/navLinks.js | 10 ++- src/lib/offerFormat.js | 110 ++++++++++++++++++++++++++++++ src/lib/storageFormat.js | 39 +++++++++++ src/lib/teamApi.js | 40 +++++++++++ tests/unit/adminFormat.test.mjs | 117 ++++++++++++++++++++++++++++++++ tests/unit/navLinks.test.mjs | 14 ++-- 13 files changed, 487 insertions(+), 47 deletions(-) delete mode 100644 src/hooks/useChatUnread.js create mode 100644 src/hooks/useSidebarCounts.js create mode 100644 src/lib/creditFormat.js create mode 100644 src/lib/format.js create mode 100644 src/lib/offerFormat.js create mode 100644 src/lib/storageFormat.js create mode 100644 tests/unit/adminFormat.test.mjs 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') })