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:
1 parent
813c6a7e18
commit
91d526e909
13 files changed
+487
-47
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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}` : ''}`
|
||||
@@ -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`)
|
||||
Reference in new issue
Block a user