Compare commits

...
5 Commits
30 changed files with 1528 additions and 54 deletions

No files matched your search

+6
View File
@@ -10,6 +10,9 @@ import AdminPage from './pages/AdminPage'
import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage'
import CustomerChatPage from './pages/CustomerChatPage'
import StorageTasksPage from './pages/StorageTasksPage'
import CreditsPage from './pages/CreditsPage'
import OffersPage from './pages/OffersPage'
import FinancePage from './pages/FinancePage'
import FinanceSettingsPage from './pages/FinanceSettingsPage'
import LegalPage from './pages/LegalPage'
@@ -52,6 +55,9 @@ function AppRoutes() {
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
<Route path="/customers/email-speicher" element={<ProtectedRoute><StorageTasksPage /></ProtectedRoute>} />
<Route path="/customers/gutschriften" element={<ProtectedRoute><CreditsPage /></ProtectedRoute>} />
<Route path="/customers/angebote" element={<ProtectedRoute><OffersPage /></ProtectedRoute>} />
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
+10 -4
View File
@@ -17,12 +17,15 @@ import {
IconLayoutSidebarLeftExpand,
IconExternalLink,
IconMessages,
IconReceiptRefund,
IconDiscount,
IconMailCog,
} from '@tabler/icons-react'
import { openProjectAdmin } from '../lib/employeeAdminApi'
import { serverpulsApi } from '../lib/serverpulsApi'
import { startsCollapsed } from '../lib/sidebar'
import { buildNavLinks } from '../lib/navLinks'
import { useChatUnread } from '../hooks/useChatUnread'
import { useSidebarCounts } from '../hooks/useSidebarCounts'
const ICONS = {
tickets: <IconTicket />,
@@ -31,6 +34,9 @@ const ICONS = {
services: <IconLayoutGrid />,
customers: <IconUsers />,
chat: <IconMessages />,
gutschriften: <IconReceiptRefund />,
angebote: <IconDiscount />,
speicher: <IconMailCog />,
leads: <IconTargetArrow />,
finance: <IconReportMoney />,
legal: <IconScale />,
@@ -45,7 +51,7 @@ export default function Sidebar() {
const [collapsed, setCollapsed] = useState(() => startsCollapsed())
const [switching, setSwitching] = useState(false)
const [pulseLevel, setPulseLevel] = useState('ok')
const chatUnread = useChatUnread()
const counts = useSidebarCounts()
// Serverpuls-Punkt: nur Admins, beim Laden und alle 5 Minuten; Fehler bleiben still
useEffect(() => {
@@ -72,7 +78,7 @@ export default function Sidebar() {
}
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js, Icons ueber key
const links = buildNavLinks({ isAdmin, pulseLevel, chatUnread })
const links = buildNavLinks({ isAdmin, pulseLevel, counts })
const isActive = (href) =>
location.pathname === href || location.pathname.startsWith(href + '/')
@@ -125,7 +131,7 @@ export default function Sidebar() {
key={child.href}
to={child.href}
className={`side-link side-sublink ${isActive(child.href) ? 'active' : ''}`}
title={child.count > 0 ? `${child.label} (${child.count} ungelesen)` : child.label}
title={child.count > 0 ? `${child.label} (${child.count} ${child.key === 'chat' ? 'ungelesen' : 'offen'})` : child.label}
>
{ICONS[child.key]}
<span className="side-link-label">{child.label}</span>
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { FaSpinner, FaArrowUpRightFromSquare } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import KundenansichtButton from '../customer/KundenansichtButton'
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
import { applyMessage, BILLING_MONTHS, calcLine, creditStatus, decisionConfirm, decisionError, decisionMessage, fmtBytes, historyLabel } from '../../lib/creditFormat'
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
import { storageLabel } from '../../lib/storageFormat'
import '../../styles/admin-areas.css'
const CHECK_TONE = { ok: 'ok', warn: 'warn', info: 'info' }
const staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
const toCents = (value) => Math.round(Number(value) * 100)
/**
* Antrag auf Uebernahmegutschrift: Pruefung und Entscheidung wie im frueheren Admin-Panel (public/admin.js 684-906).
* Waehrend einer Entscheidung sind alle Knoepfe gesperrt (busyRef verhindert auch Doppelklicks vor dem Neuzeichnen).
*/
export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
const [d, setD] = useState(null)
const [error, setError] = useState(null)
const [message, setMessage] = useState('')
const [form, setForm] = useState(null)
const [calcText, setCalcText] = useState('')
const [actionError, setActionError] = useState('')
const [busy, setBusy] = useState(false)
const busyRef = useRef(false)
const amountTouched = useRef(false)
const calcTimer = useRef(null)
const load = useCallback(async (msg = '') => {
setError(null)
try {
const detail = await getCredit(id)
const r = detail.request
setD(detail)
setMessage(msg)
setActionError('')
amountTouched.current = false
setForm({
checks: Object.fromEntries(detail.requiredChecks.map((k) => [k.key, false])),
invoiceAmount: (r.invoiceAmountCents / 100).toFixed(2),
billingMonths: r.billingMonths,
paidUntil: r.paidUntilDate || '',
takeoverFrom: r.takeoverFromDate || '',
amount: ((detail.calc?.suggestedCents || 0) / 100).toFixed(2),
note: '',
})
setCalcText(detail.calc ? calcLine(detail.calc) : 'Angaben unvollständig')
} catch (err) {
setError(err.message || 'Antrag konnte nicht geladen werden.')
}
}, [id])
useEffect(() => {
setD(null)
load()
return () => clearTimeout(calcTimer.current)
}, [load])
const figures = (f) => ({ invoiceAmountCents: toCents(f.invoiceAmount), billingMonths: Number(f.billingMonths), paidUntil: f.paidUntil, takeoverFrom: f.takeoverFrom })
const setFigure = (key, value) => {
const next = { ...form, [key]: value }
setForm(next)
clearTimeout(calcTimer.current)
calcTimer.current = setTimeout(async () => {
try {
const { calc } = await calcCredit(figures(next))
setCalcText(calcLine(calc))
if (!amountTouched.current) setForm((f) => ({ ...f, amount: (calc.suggestedCents / 100).toFixed(2) }))
} catch (err) {
setCalcText(err.message)
}
}, 300)
}
const decide = async (action) => {
if (busyRef.current) return
setActionError('')
const problem = decisionError(action, form.note)
if (problem) { setActionError(problem); return }
const amountCents = toCents(form.amount)
if (!window.confirm(decisionConfirm(action, amountCents, d.customer?.name))) return
if (busyRef.current) return
busyRef.current = true
setBusy(true)
try {
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
onChanged?.()
await load(decisionMessage(action, result))
} catch (err) {
setActionError(err.message)
} finally {
busyRef.current = false
setBusy(false)
}
}
const apply = async () => {
if (busyRef.current) return
busyRef.current = true
setBusy(true)
try {
const result = await applyCredit(id)
onChanged?.()
await load(applyMessage(result))
} catch (err) {
setActionError(err.message)
} finally {
busyRef.current = false
setBusy(false)
}
}
if (error) return <Card><div className="area-error">{error}</div><Button variant="ghost" onClick={onClose}>Schließen</Button></Card>
if (!d || !form) return <Card><FaSpinner className="spinner" /></Card>
const r = d.request
const c = d.customer
const s = d.subscription
const st = creditStatus(r.status)
const pending = r.status === 'offen' || r.status === 'rueckfrage'
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
const allChecked = Object.values(form.checks).every(Boolean)
const canApprove = allChecked && Number(form.amount) > 0 && !busy
return (
<Card>
<div className="area-inline" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div>
<h3 style={{ margin: 0 }}>Antrag {r.ref} · {c.name || c.companyName}</h3>
<span className="area-muted">eingegangen {fmtDateTime(r.createdAt)}</span> <Badge tone={st.tone}>{st.label}</Badge>
</div>
<Button variant="ghost" onClick={onClose}>Schließen</Button>
</div>
{message && <div className="area-message">{message}</div>}
<div className="credit-cols">
<div>
<h4>Antrag</h4>
<dl className="area-kv">
<dt>Leistung</dt><dd>{r.services}</dd>
<dt>Bisher bei</dt><dd>{r.provider}</dd>
<dt>Domain</dt><dd>{r.domain ? <a href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
<dt>Antwort an</dt><dd>{r.email}</dd>
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
</dl>
<h4>Grund / Nachricht</h4>
<p style={{ whiteSpace: 'pre-wrap' }}>{r.message}</p>
<h4>Nachweise</h4>
{r.files.length ? (
<ul className="credit-files">
{r.files.map((f) => (
<li key={f.id}>
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="area-muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
</li>
))}
</ul>
) : <p className="area-muted">Keine Nachweise (mehr) vorhanden.</p>}
<h4>Verlauf</h4>
{r.history.map((h, i) => (
<div key={i} className="credit-history">
<strong>{historyLabel(h.type)}</strong> <span className="area-muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
{h.text && <div style={{ whiteSpace: 'pre-wrap' }}>{h.text}</div>}
{(h.files || []).length > 0 && <div className="area-muted">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
</div>
))}
</div>
<div>
<h4>Kunde &amp; Vertrag</h4>
<dl className="area-kv">
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
<dt>Abo</dt>
<dd>
{s ? <>{productLabelOf(s.productKey)} <Badge tone={s.active ? 'ok' : 'danger'}>{s.status}</Badge><br /><span className="area-muted">Zusatzspeicher: {storageLabel(s.storageUnits)}</span></> : <Badge tone="danger">kein Abo</Badge>}
</dd>
{s && (
<>
<dt>Leistungsbeginn</dt><dd>{fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>{s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
{s.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(s.cancelAt)}</dd></>}
<dt>Stripe-Guthaben</dt><dd>{s.creditBalanceCents == null ? '–' : formatCents(s.creditBalanceCents)}</dd>
</>
)}
</dl>
<div className="area-inline" style={{ margin: '12px 0' }}>
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} variant="ghost" />
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer" variant="ghost"><FaArrowUpRightFromSquare /> In Stripe öffnen</Button>}
</div>
{d.otherRequests.length > 0 && (
<>
<h4>Weitere Anträge des Kunden</h4>
<ul className="credit-files">
{d.otherRequests.map((o) => {
const ost = creditStatus(o.status)
return (
<li key={o.id}>
<a href="#" onClick={(e) => { e.preventDefault(); onOpen?.(o.id) }}>{o.ref}</a> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
</li>
)
})}
</ul>
</>
)}
<h4>Automatische Prüfung</h4>
<ul className="credit-checks">
{d.autoChecks.map((k) => (
<li key={k.key}><Badge tone={CHECK_TONE[k.status] || 'muted'}>{k.status === 'ok' ? '✓' : k.status === 'warn' ? '!' : 'i'}</Badge> {k.label}{k.detail && <><br /><small className="area-muted">{k.detail}</small></>}</li>
))}
</ul>
{pending ? (
<>
<h4>Prüfung durch dich</h4>
<div className="credit-manual">
{d.requiredChecks.map((k) => (
<label key={k.key}>
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
</label>
))}
</div>
<h4>Berechnung</h4>
<div className="credit-calc">
<label>Rechnungsbetrag (€)<input id="calc-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} /></label>
<label>Rechnung gilt für
<select id="calc-months" className="form-control" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
</select>
</label>
<label>Bezahlt bis<input id="calc-paid-until" className="form-control" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} /></label>
<label>Übernahme ab<input id="calc-from" className="form-control" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} /></label>
</div>
<div className="area-summary">{calcText}</div>
<p className="area-muted">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
<label className="credit-field">Gutschrift (€)
<input id="credit-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.amount} style={{ maxWidth: 180 }}
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
</label>
<label className="credit-field">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
<textarea id="credit-note" className="form-control" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</label>
<div className="area-inline">
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>Genehmigen &amp; gutschreiben</Button>
<Button variant="ghost" onClick={() => decide('question')} disabled={busy}>Rückfrage senden</Button>
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>Ablehnen</Button>
{busy && <FaSpinner className="spinner" />}
</div>
<p className="area-muted">
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
</p>
</>
) : (
<>
<h4>Entscheidung</h4>
{r.status === 'genehmigt' ? (
<>
<dl className="area-kv">
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
<dt>Berechnung</dt><dd>{r.calculation}</dd>
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</dd>
{r.decisionNote && <><dt>Nachricht</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd></>}
<dt>Verrechnung</dt>
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="area-muted">({r.stripeBalanceTransactionId})</span></> : <Badge tone="warn">noch nicht in Stripe gebucht</Badge>}</dd>
</dl>
{!r.appliedAt && <div className="area-inline" style={{ marginTop: 12 }}><Button variant="primary" onClick={apply} disabled={busy}>Jetzt in Stripe gutschreiben</Button></div>}
</>
) : (
<dl className="area-kv">
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd>
</dl>
)}
</>
)}
{actionError && <div className="area-error">{actionError}</div>}
</div>
</div>
</Card>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import { listCredits } from '../../lib/teamApi'
import { creditStatus } from '../../lib/creditFormat'
import { fmtDate, fmtDateTime, formatCents } from '../../lib/format'
import '../../styles/admin-areas.css'
// Liste der Uebernahmegutschriften (Filter "Offen"/"Alle"), wie im frueheren Admin-Panel; optional nur ein Kunde
export default function CreditList({ customerId, selectedId, onSelect, reloadKey }) {
const [filter, setFilter] = useState('open')
const [rows, setRows] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
let alive = true
setError(null)
listCredits({ status: filter, customerId })
.then(({ requests }) => { if (alive) setRows(requests || []) })
.catch((err) => { if (alive) setError(err.message || 'Anträge konnten nicht geladen werden.') })
return () => { alive = false }
}, [filter, customerId, reloadKey])
return (
<div>
<div className="area-filter seg">
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen</button>
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
</div>
<Card pad={false}>
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
{!rows && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
{rows && (
<div className="area-rows">
<div className="area-row credit-row head"><span>Eingang</span><span>Kunde</span><span>Leistung / bisher bei</span><span>Rechnung</span><span>Bezahlt bis</span><span>Status</span><span /></div>
{rows.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</span></div>}
{rows.map((r) => {
const st = creditStatus(r.status)
return (
<div key={r.id} className={`area-row credit-row ${r.id === selectedId ? 'active' : ''}`}>
<span>{fmtDateTime(r.createdAt)}<br /><span className="area-muted">{r.ref}</span></span>
<span><strong>{r.customerName}</strong>{r.customerCode && <><br /><span className="area-muted">Kd.-Nr. {r.customerCode}</span></>}</span>
<span>{r.services}<br /><span className="area-muted">{r.provider}{r.domain ? ` · ${r.domain}` : ''}</span></span>
<span>{formatCents(r.invoiceAmountCents)}<br /><span className="area-muted">für {r.billingMonths} Mon.</span></span>
<span>{fmtDate(r.paidUntil)}</span>
<span>
<Badge tone={st.tone}>{st.label}</Badge>
{r.status === 'genehmigt' && <><br /><span className="area-muted">{formatCents(r.amountCents)}{r.appliedAt ? '' : ' · nicht gebucht'}</span></>}
</span>
<span>
<Button size="sm" variant="primary" onClick={() => onSelect(r.id)}>
{['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}
</Button>
</span>
</div>
)
})}
</div>
)}
</Card>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import CreditList from './CreditList'
import CreditDetail from './CreditDetail'
/**
* Gutschriften: Liste und Antrag (Adresse ?antrag=<id>, andere Parameter wie ?tab= bleiben). Sammelliste unter
* Kunden -> Gutschriften und Reiter "Gutschriften" auf der Kundenseite (mit customerId).
*/
export default function CreditsView({ customerId }) {
const [params, setParams] = useSearchParams()
const selected = params.get('antrag') || ''
const [reloadKey, setReloadKey] = useState(0)
const select = (id) =>
setParams((prev) => {
const next = new URLSearchParams(prev)
if (id) next.set('antrag', id)
else next.delete('antrag')
return next
})
return (
<div className="credits-view">
{selected && (
<div style={{ marginBottom: 16 }}>
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
</div>
)}
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge } from '../ui'
import { getCustomerAboStatus } from '../../lib/teamApi'
import { fmtDate, productLabelOf } from '../../lib/format'
import { storageLabel } from '../../lib/storageFormat'
import '../../styles/admin-areas.css'
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
export default function AboStatusCard({ customerId }) {
const [abo, setAbo] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
let alive = true
setAbo(null)
setError(null)
getCustomerAboStatus(customerId)
.then((a) => { if (alive) setAbo(a) })
.catch((err) => { if (alive) setError(err.message || 'Abo konnte nicht geladen werden.') })
return () => { alive = false }
}, [customerId])
return (
<Card title="Abo">
{error && <div className="area-error">{error}</div>}
{!abo && !error && <FaSpinner className="spinner" />}
{abo && !abo.status && <span className="area-muted">kein Abo</span>}
{abo && abo.status && (
<dl className="area-kv">
<dt>Abo</dt>
<dd className="area-inline">
{productLabelOf(abo.productKey)}
<Badge tone={abo.active ? 'ok' : 'danger'}>{abo.active ? 'aktiv' : abo.status}</Badge>
</dd>
<dt>Leistungsbeginn</dt><dd>{fmtDate(abo.serviceStart)}</dd>
{abo.active && <><dt>Nächste Abbuchung</dt><dd>{fmtDate(abo.currentPeriodEnd)}</dd></>}
{abo.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(abo.cancelAt)}</dd></>}
<dt>Zusatzspeicher</dt><dd>{storageLabel(abo.storageUnits)}</dd>
</dl>
)}
</Card>
)
}
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui'
import { getFunktionen, setNewsFeature } from '../../lib/teamApi'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
const STATE = {
off: ['muted', 'Aus'],
ordered: ['warn', 'Bestellt'],
setup: ['info', 'Einrichtung'],
active: ['ok', 'Aktiv'],
disabled: ['muted', 'Abgeschaltet'],
}
const CONFIRM = {
activate: 'News ohne Zahlung aktivieren?',
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
}
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
export default function CustomerFunktionen({ customerId }) {
const [projects, setProjects] = useState(null)
const [error, setError] = useState(null)
const [confirm, setConfirm] = useState(null) // { projectId, action }
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState(null) // { ok, text }
const load = useCallback(() => {
setError(null)
getFunktionen(customerId)
.then(({ projects: list }) => setProjects(list || []))
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
}, [customerId])
useEffect(() => { load() }, [load])
const save = async () => {
setBusy(true)
setStatus(null)
try {
const { projects: changed } = await setNewsFeature(customerId, { projectId: confirm.projectId, action: confirm.action, note: note.trim() })
const updated = changed?.[0]
if (updated) setProjects((list) => list.map((p) => (p.projectId === updated.projectId ? updated : p)))
setConfirm(null)
setNote('')
setStatus({ ok: true, text: 'Gespeichert.' })
} catch (err) {
setConfirm(null)
setStatus({ ok: false, text: err.message })
} finally {
setBusy(false)
}
}
if (error) return <Card><div className="area-error">{error}</div></Card>
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card>
return (
<Card title="Funktionen" pad={false}>
<div className="area-rows">
<div className="area-row funktionen-row head"><span>Projekt</span><span>News</span><span>Ticket</span><span>Aktionen</span></div>
{projects.map((p) => {
const news = p.features?.news || { state: 'off' }
const [tone, label] = STATE[news.state] || ['muted', news.state]
const isConfirming = confirm?.projectId === p.projectId
return (
<div key={p.projectId} className="area-row funktionen-row">
<span>
<strong>{p.projectName}</strong>
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
</span>
<span>
<Badge tone={tone}>{label}</Badge>
{news.adminNote && <><br /><span className="area-muted" title={news.adminNote}>{short(news.adminNote)}</span></>}
</span>
<span>{news.ticketMissing ? <Badge tone="danger">Ticket fehlt</Badge> : news.ticketWoid ? `#${news.ticketWoid}` : '–'}</span>
<span className="area-inline">
{isConfirming ? (
<>
<span>{CONFIRM[confirm.action]}</span>
<input className="form-control" placeholder="Notiz (optional)" maxLength={120} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: 220 }} />
<Button size="sm" variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</>
) : (
<>
{news.state !== 'active' && (
<Button size="sm" variant="ghost" onClick={() => { setNote(''); setConfirm({ projectId: p.projectId, action: 'activate' }) }}>Aktivieren (ohne Zahlung)</Button>
)}
{['ordered', 'setup', 'active'].includes(news.state) && (
<Button size="sm" variant="ghost" onClick={() => { setNote(''); setConfirm({ projectId: p.projectId, action: 'disable' }) }}>Abschalten</Button>
)}
</>
)}
</span>
</div>
)
})}
</div>
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
</Card>
)
}
@@ -4,10 +4,13 @@ import { Button } from '../ui'
import { createKundenansichtLink } from '../../lib/teamApi'
import { openInNewTab } from '../../lib/openInNewTab'
/** Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut. */
export default function KundenansichtButton({ customer, variant = 'primary' }) {
/**
* Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut.
* checkAccess=false: Kunden-Dokument liegt nicht vor (z. B. Gutschrift-Detail), den Zugang prüft dann der Server (409).
*/
export default function KundenansichtButton({ customer, variant = 'primary', checkAccess = true }) {
const [busy, setBusy] = useState(false)
const hasAccess = Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
const open = async () => {
setBusy(true)
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Button, AutocompleteInput } from '../ui'
import { useCustomers } from '../../hooks/useCustomers'
import { createOffer, getOfferContext } from '../../lib/teamApi'
import { customerPickLabel, findCustomerByLabel } from '../../lib/chatFormat'
import { createdMessage, evaluateOfferForm, offerConfirm } from '../../lib/offerFormat'
import '../../styles/admin-areas.css'
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
/**
* "Neues Angebot" wie im frueheren Admin-Panel (public/admin.js 1131-1311). Der Kontext (Abo, offene Angebote, Preise)
* wird genau einmal je gewaehltem Kunden geladen; beim Wechsel sofort geleert, spaete Antworten verworfen. Vorschau
* und Speichern erst mit dem Kontext des aktuellen Kunden.
*/
export default function OfferForm({ fixedCustomerId, fixedCustomerName, onCreated, onCancel }) {
const { customers } = useCustomers()
const [label, setLabel] = useState('')
const [pickedId, setPickedId] = useState('')
const customerId = fixedCustomerId || pickedId
const [productKey, setProductKey] = useState('wartung')
const [price, setPrice] = useState('25.00')
const [months, setMonths] = useState(2)
const [validUntil, setValidUntil] = useState('')
const [note, setNote] = useState('')
const [internalNote, setInternalNote] = useState('')
const [notify, setNotify] = useState(true)
const [ctx, setCtx] = useState(null) // { customerId, data } | { customerId, error }
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setCtx(null)
if (!customerId) return undefined
let alive = true
getOfferContext(customerId)
.then((data) => {
if (!alive) return
setCtx({ customerId, data })
setValidUntil((v) => v || data.defaultValidUntil)
})
.catch((err) => { if (alive) setCtx({ customerId, error: err.message || 'Kunde konnte nicht geladen werden.' }) })
return () => { alive = false }
}, [customerId])
const values = {
customerId,
productKey,
monthlyPriceCents: Math.round(Number(price) * 100),
months: Number(months),
validUntil,
note: note.trim(),
internalNote: internalNote.trim(),
notifyCustomer: notify,
}
const ready = ctx && ctx.customerId === customerId && ctx.data
const evaluation = useMemo(() => (ready ? evaluateOfferForm(ctx.data, values) : null), [ready, ctx, productKey, price, months, validUntil, customerId]) // eslint-disable-line react-hooks/exhaustive-deps
const suggestions = (text) => {
const q = String(text || '').trim().toLowerCase()
if (!q) return []
return customers.map(customerPickLabel).filter((l) => l.toLowerCase().includes(q)).slice(0, 8).map((value) => ({ value }))
}
const onPick = (value) => {
setLabel(value)
setPickedId(findCustomerByLabel(customers, value)?.$id || '')
}
const save = async () => {
if (!evaluation || evaluation.errors.length || busy) return
setError('')
if (!window.confirm(offerConfirm(ctx.data, values, evaluation.running))) return
setBusy(true)
try {
const result = await createOffer(values)
onCreated?.(createdMessage(result, values.notifyCustomer))
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
let previewBox
if (!customerId) previewBox = <div>Bitte einen Kunden aus der Liste wählen.</div>
else if (ctx?.error && ctx.customerId === customerId) previewBox = <div className="area-danger">{ctx.error}</div>
else if (!ready) previewBox = <div>Lade Kunde…</div>
else {
previewBox = (
<>
{evaluation.errors.map((l) => <div key={l} className="area-danger">{l}</div>)}
{evaluation.previewLines.map((l) => <div key={l}>{l}</div>)}
</>
)
}
return (
<Card className="offer-form">
<h3 style={{ marginTop: 0 }}>Neues Angebot</h3>
<div className="credit-calc">
<label style={{ gridColumn: '1 / -1' }}>Kunde
{fixedCustomerId
? <strong style={{ color: 'var(--text)' }}>{fixedCustomerName || 'Kunde'}</strong>
: <AutocompleteInput value={label} onChange={onPick} getSuggestions={suggestions} placeholder="Name, E-Mail oder Kundennummer tippen …" />}
</label>
<label>Produkt
<select className="form-control" value={productKey} onChange={(e) => setProductKey(e.target.value)}>
<option value="wartung">Wartungs-Abo</option>
<option value="hosting">Hosting-Abo</option>
</select>
</label>
<label>Preis pro Monat im Angebot (€)
<input className="form-control" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
</label>
<label>für
<select className="form-control" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
{MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
</select>
</label>
<label>Angebot gültig bis
<input className="form-control" type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
</label>
</div>
<label className="credit-field">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)
<input className="form-control" maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
<label className="credit-field">Interne Notiz (optional, sieht nur das Team)
<input className="form-control" maxLength={2000} value={internalNote} onChange={(e) => setInternalNote(e.target.value)} />
</label>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} /> Kunden per E-Mail über das Angebot informieren
</label>
<div className="area-summary">{previewBox}</div>
<div className="area-inline">
<Button variant="primary" onClick={save} disabled={!evaluation || evaluation.errors.length > 0 || busy}>
{busy ? <FaSpinner className="spinner" /> : evaluation?.saveLabel || 'Angebot anlegen'}
</Button>
<Button variant="ghost" onClick={onCancel}>Abbrechen</Button>
</div>
{error && <div className="area-error">{error}</div>}
</Card>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import { listOffers, notifyOffer, withdrawOffer } from '../../lib/teamApi'
import { discountLine, offerStatus } from '../../lib/offerFormat'
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
import '../../styles/admin-areas.css'
// Liste der Abo-Angebote (Filter "Offen & aktiv"/"Alle") wie im frueheren Admin-Panel; optional nur ein Kunde
export default function OfferList({ customerId, reloadKey, onMessage, onChanged }) {
const [filter, setFilter] = useState('open')
const [offers, setOffers] = useState(null)
const [error, setError] = useState(null)
const [busyId, setBusyId] = useState('')
useEffect(() => {
let alive = true
setError(null)
listOffers({ customerId })
.then(({ offers: list }) => { if (alive) setOffers(list || []) })
.catch((err) => { if (alive) setError(err.message || 'Angebote konnten nicht geladen werden.') })
return () => { alive = false }
}, [customerId, reloadKey])
const withdraw = async (o) => {
if (!window.confirm('Angebot zurückziehen? Der Coupon wird in Stripe gelöscht, der Kunde sieht das Angebot nicht mehr.')) return
setBusyId(o.id)
try {
await withdrawOffer(o.id)
onMessage?.('Angebot zurückgezogen, Coupon in Stripe gelöscht.')
onChanged?.()
} catch (err) {
window.alert('Zurückziehen fehlgeschlagen: ' + err.message)
} finally {
setBusyId('')
}
}
const sendMail = async (o) => {
const again = o.notifiedAt ? `\n\nZuletzt gesendet ${fmtDateTime(o.notifiedAt)}.` : ''
if (!window.confirm(`E-Mail mit dem Angebot an ${o.customerName} (${o.customerEmail}) ${o.notifiedAt ? 'erneut ' : ''}senden?${again}`)) return
setBusyId(o.id)
try {
await notifyOffer(o.id)
onMessage?.(`Die E-Mail an ${o.customerEmail} ist raus, der Mailserver hat sie angenommen. Kommt sie nicht an: Spam-Ordner prüfen, bei Gmail auch den Tab „Werbung“.`)
onChanged?.()
} catch (err) {
window.alert(err.message)
} finally {
setBusyId('')
}
}
const shown = (offers || []).filter((o) => filter === 'all' || ['offen', 'aktiv'].includes(o.status))
return (
<div>
<div className="area-filter seg">
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen &amp; aktiv</button>
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
</div>
<Card pad={false}>
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
{!offers && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
{offers && (
<div className="area-rows">
<div className="area-row offer-row head"><span>Angelegt</span><span>Kunde</span><span>Angebot</span><span>Gültig bis</span><span>Status</span></div>
{shown.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</span></div>}
{shown.map((o) => {
const st = offerStatus(o.status)
const discount = discountLine(o)
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
return (
<div key={o.id} className="area-row offer-row">
<span>{fmtDateTime(o.createdAt)}<br /><span className="area-muted">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</span></span>
<span>
<strong>{o.customerName}</strong>
{o.customerEmail && <><br /><span className="area-muted">{o.customerEmail}</span></>}
{o.customerCode && <><br /><span className="area-muted">Kd.-Nr. {o.customerCode}</span></>}
</span>
<span>
<span className="area-muted">{productLabelOf(o.productKey)}</span><br />{o.text}
<br /><span className="area-muted">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</span>
{discount && <><br /><span className="area-muted">{discount}</span></>}
{o.internalNote && <><br /><span className="area-muted"><em>intern: {o.internalNote}</em></span></>}
</span>
<span>{o.scope === 'laufend' ? <span className="area-muted">laufendes Abo</span> : fmtDate(o.validUntil)}</span>
<span>
<Badge tone={st.tone}>{st.label}</Badge>
{sub && <><br /><span className="area-muted">{sub}</span></>}
<br /><span className="area-muted">Stripe:</span>{' '}
{o.stripeCouponUrl ? <a href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
{o.stripeSubscriptionUrl && <> · <a href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
{o.notifiedAt
? <><br /><span className="area-muted">E-Mail gesendet {fmtDateTime(o.notifiedAt)}</span></>
: o.mailSendable
? <><br /><span className="area-warn">E-Mail nicht verschickt</span></>
: o.status === 'offen' && !o.customerEmail ? <><br /><span className="area-warn">Keine E-Mail-Adresse hinterlegt</span></> : null}
{(['offen', 'abgelaufen'].includes(o.status) || o.mailSendable) && (
<span className="area-inline" style={{ marginTop: 6 }}>
{o.mailSendable && (
<Button size="sm" variant="ghost" title={`Angebots-E-Mail an ${o.customerEmail} senden`} onClick={() => sendMail(o)} disabled={busyId === o.id}>
{o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'}
</Button>
)}
{['offen', 'abgelaufen'].includes(o.status) && (
<Button size="sm" variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>
)}
</span>
)}
</span>
</div>
)
})}
</div>
)}
</Card>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { useState } from 'react'
import { FaPlus } from 'react-icons/fa6'
import { Button } from '../ui'
import OfferList from './OfferList'
import OfferForm from './OfferForm'
// Abo-Angebote: Meldung, "Neues Angebot", Formular und Liste. Sammelliste (ohne customerId) und Kunden-Reiter.
export default function OffersView({ customerId, customerName }) {
const [message, setMessage] = useState('')
const [showForm, setShowForm] = useState(false)
const [reloadKey, setReloadKey] = useState(0)
const reload = () => setReloadKey((k) => k + 1)
return (
<div>
{message && <div className="area-message">{message}</div>}
{!showForm && (
<div style={{ marginBottom: 12 }}>
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}><FaPlus /> Neues Angebot</Button>
</div>
)}
{showForm && (
<div style={{ marginBottom: 16 }}>
<OfferForm
fixedCustomerId={customerId}
fixedCustomerName={customerName}
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
onCancel={() => setShowForm(false)}
/>
</div>
)}
<OfferList customerId={customerId} reloadKey={reloadKey} onMessage={setMessage} onChanged={reload} />
</div>
)
}
-31
View File
@@ -1,31 +0,0 @@
import { useEffect, useState } from 'react'
import { getChatUnreadCount } from '../lib/teamApi'
// Zahl ungelesener Kundennachrichten fuer die Sidebar: jede Minute bei sichtbarem Tab und sofort beim
// Zurueckkehren; Fehler behalten den letzten Wert.
const POLL_MS = 60 * 1000
export function useChatUnread() {
const [unread, setUnread] = useState(0)
useEffect(() => {
let dead = false
const load = () => {
if (document.visibilityState === 'hidden') return
getChatUnreadCount()
.then((r) => { if (!dead) setUnread(Number(r?.unread) || 0) })
.catch(() => {})
}
load()
const iv = setInterval(load, POLL_MS)
const onVisible = () => { if (document.visibilityState === 'visible') load() }
document.addEventListener('visibilitychange', onVisible)
return () => {
dead = true
clearInterval(iv)
document.removeEventListener('visibilitychange', onVisible)
}
}, [])
return unread
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { getCustomerAbo } from '../lib/customerAdminApi'
// Team-Eingang: alle Mitarbeiter (vorher Admin-Route, Nicht-Admins bekamen 403 und damit immer den Schalter)
import { getCustomerAboStatus as getCustomerAbo } from '../lib/teamApi'
// Ergebnis je Kunde zwischengespeichert (nur Erfolge), laufende Abfragen werden geteilt
const cache = new Map()
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
import { getChatUnreadCount, getCreditOpenCount, getStorageOpenCount } from '../lib/teamApi'
// Zahlen in der Sidebar unter Kunden: ungelesene Chats, offene Gutschrift-Antraege, offene Speicher-Aufgaben.
// Jede Minute bei sichtbarem Tab und sofort beim Zurueckkehren; Fehler behalten den letzten Wert.
const POLL_MS = 60 * 1000
const SOURCES = [
['chat', () => getChatUnreadCount().then((r) => r?.unread)],
['credits', () => getCreditOpenCount().then((r) => r?.open)],
['storage', () => getStorageOpenCount().then((r) => r?.open)],
]
export function useSidebarCounts() {
const [counts, setCounts] = useState({ chat: 0, credits: 0, storage: 0 })
useEffect(() => {
let dead = false
const load = () => {
if (document.visibilityState === 'hidden') return
for (const [key, fetchCount] of SOURCES) {
fetchCount()
.then((n) => { if (!dead) setCounts((c) => ({ ...c, [key]: Number(n) || 0 })) })
.catch(() => {})
}
}
load()
const iv = setInterval(load, POLL_MS)
const onVisible = () => { if (document.visibilityState === 'visible') load() }
document.addEventListener('visibilitychange', onVisible)
return () => {
dead = true
clearInterval(iv)
document.removeEventListener('visibilitychange', onVisible)
}
}, [])
return counts
}
+64
View File
@@ -0,0 +1,64 @@
import { fmtDate, formatCents } from './format'
// Uebernahmegutschriften: Texte und Pruefungen wortgleich aus dem Admin-Panel (public/admin.js 535-906).
export const CREDIT_STATUS = {
offen: { tone: 'warn', label: 'Zu prüfen' },
rueckfrage: { tone: 'muted', label: 'Rückfrage beim Kunden' },
genehmigt: { tone: 'ok', label: 'Genehmigt' },
abgelehnt: { tone: 'danger', label: 'Abgelehnt' },
}
export const creditStatus = (status) => CREDIT_STATUS[status] || { tone: 'muted', label: status }
const HISTORY_LABELS = {
eingereicht: 'Antrag eingereicht',
rueckfrage: 'Rückfrage an den Kunden',
antwort: 'Antwort des Kunden',
genehmigt: 'Genehmigt',
abgelehnt: 'Abgelehnt',
verrechnet: 'In Stripe gutgeschrieben',
'nachweise-geloescht': 'Nachweise gelöscht',
}
export const historyLabel = (type) => HISTORY_LABELS[type] || type
export const BILLING_MONTHS = [1, 3, 6, 12, 24, 36]
export function calcLine(calc) {
return `Rechnungszeitraum ${fmtDate(calc.periodStart)} – ${fmtDate(calc.periodEnd)} (${calc.periodDays} Tage). ` +
`Doppelt bezahlt ab ${fmtDate(calc.overlapStart)}: ${calc.overlapDays} Tage → Vorschlag ${formatCents(calc.suggestedCents)}`
}
/** Fehlertext vor dem Absenden (Rueckfrage/Ablehnung brauchen eine Nachricht ab 5 Zeichen) oder null */
export function decisionError(action, note) {
if (action === 'approve' || String(note || '').trim().length >= 5) return null
return action === 'question'
? 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.'
: 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.'
}
export function decisionConfirm(action, amountCents, customerName) {
const who = customerName || 'den Kunden'
return {
approve: `Gutschrift über ${formatCents(amountCents)} genehmigen, in Stripe gutschreiben und ${who} per E-Mail informieren?`,
question: `Rückfrage per E-Mail an ${who} senden?`,
reject: `Antrag ablehnen und ${who} die Begründung per E-Mail senden?`,
}[action]
}
export function decisionMessage(action, result) {
const parts = [{ genehmigt: 'Genehmigt.', rueckfrage: 'Rückfrage gesendet.', abgelehnt: 'Abgelehnt.' }[result.status] || 'Gespeichert.']
if (action === 'approve') {
parts.push(result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Noch nicht in Stripe gebucht: ${result.reason}`)
}
parts.push(result.mailSent
? 'Die E-Mail an den Kunden ist raus.'
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError || 'unbekannt'}) – bitte selbst informieren.`)
return parts.join(' ')
}
export const applyMessage = (result) => (result.applied ? 'Das Guthaben ist in Stripe gebucht.' : `Nicht gebucht: ${result.reason}`)
export function fmtBytes(bytes) {
const n = Number(bytes || 0)
return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`
}
-5
View File
@@ -34,8 +34,3 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
export async function deleteCustomerWithPortalAccess(customerId) {
return adminFetch(`/api/admin/customers/${customerId}`, { method: 'DELETE' })
}
// Wartungs-/Hosting-Abo des Kunden: { productKey: 'wartung' | 'hosting' | null, active }
export async function getCustomerAbo(customerId) {
return adminFetch(`/api/admin/customers/${encodeURIComponent(customerId)}/abo`, { method: 'GET' })
}
+4
View File
@@ -8,6 +8,10 @@ const BASE_TABS = [
{ id: 'projects', label: 'Projekte' },
// Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat)
{ id: 'chat', label: 'Chat' },
// Bereiche aus dem frueheren Admin-Panel (Etappe 3)
{ id: 'abo', label: 'Abo & Angebote' },
{ id: 'gutschriften', label: 'Gutschriften' },
{ id: 'funktionen', label: 'Funktionen' },
// Protokoll der Kundenansicht (wer wann das Portal des Kunden geöffnet oder darin etwas geändert hat): alle Mitarbeiter
{ id: 'protokoll', label: 'Protokoll' },
]
+49
View File
@@ -0,0 +1,49 @@
// Gemeinsame Formate fuer die Bereiche aus dem Admin-Panel (Gutschriften, Angebote, E-Mail-Speicher).
// Deutsche Zeit (Europe/Berlin), Euro wie Intl de-DE. Rein, unter Node testbar.
const EURO = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
/** 123456 -> "1.234,56 €" (mit geschuetztem Leerzeichen wie Intl) */
export const formatCents = (cents) => EURO.format((Number(cents) || 0) / 100)
/** "12,5" / "12.50" -> 1250; leer oder ungueltig -> null */
export function parseEuroToCents(text) {
const s = String(text ?? '').trim().replace(/\s|€/g, '')
if (!s) return null
const normal = s.includes(',') ? s.replace(/\./g, '').replace(',', '.') : s
if (!/^-?\d+(\.\d{1,2})?$/.test(normal)) return null
return Math.round(Number(normal) * 100)
}
const parts = (value, options) =>
Object.fromEntries(
new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hourCycle: 'h23', ...options })
.formatToParts(new Date(value))
.map((p) => [p.type, p.value])
)
const valid = (value) => value && !Number.isNaN(new Date(value).getTime())
/** dd.mm.yyyy (deutsche Zeit); leer -> "–" */
export function fmtDate(value) {
if (!valid(value)) return value ? String(value) : '–'
const p = parts(value, { day: '2-digit', month: '2-digit', year: 'numeric' })
return `${p.day}.${p.month}.${p.year}`
}
/** dd.mm.yy, hh:mm (deutsche Zeit) wie im Admin-Panel; leer -> "–" */
export function fmtDateTime(value) {
if (!valid(value)) return value ? String(value) : '–'
const p = parts(value, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute}`
}
/** Datumsfeld "2026-10-27" -> "27.10.2026" ohne Zeitzonen-Umweg */
export const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.')
/** Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD) */
export const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
export const fmtBerlin = (value) => fmtYmd(berlinYmd(value))
export const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
export const productLabelOf = (key) => PRODUCT_LABEL[key] || (key ? String(key) : 'Abo')
+8 -2
View File
@@ -1,7 +1,7 @@
// Eintraege der Sidebar als reine Daten (unter Node testbar); Navbar.jsx ordnet die Icons ueber key zu.
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0).
export function buildNavLinks({ isAdmin, pulseLevel, chatUnread }) {
export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
return [
{ key: 'tickets', label: 'Tickets', href: '/tickets' },
{ key: 'planboard', label: 'Planboard', href: '/planboard' },
@@ -11,7 +11,13 @@ export function buildNavLinks({ isAdmin, pulseLevel, chatUnread }) {
key: 'customers',
label: 'Kunden',
href: '/customers',
children: [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: chatUnread }],
// Bereiche aus dem frueheren Admin-Panel; Zahlen: ungelesene Chats, offene Gutschriften, offene Speicher-Aufgaben
children: [
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0 },
{ key: 'gutschriften', label: 'Gutschriften', href: '/customers/gutschriften', count: counts.credits ?? 0 },
{ key: 'angebote', label: 'Abo-Angebote', href: '/customers/angebote' },
{ key: 'speicher', label: 'E-Mail-Speicher', href: '/customers/email-speicher', count: counts.storage ?? 0 },
],
},
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ key: 'leads', label: 'Leads', href: '/leads', paused: true },
+110
View File
@@ -0,0 +1,110 @@
import { berlinYmd, fmtBerlin, fmtDate, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
// Abo-Angebote: Texte, Rabattende und Formularpruefung wortgleich aus dem Admin-Panel (public/admin.js 984-1317).
// "today" ist der Kalendertag in Deutschland (YYYY-MM-DD) und fuer Tests injizierbar.
export const OFFER_STATUS = {
offen: { tone: 'warn', label: 'offen' },
aktiv: { tone: 'ok', label: 'aktiv' },
abgelaufen: { tone: 'muted', label: 'abgelaufen' },
zurueckgezogen: { tone: 'muted', label: 'zurückgezogen' },
}
export const offerStatus = (status) => OFFER_STATUS[status] || { tone: 'muted', label: status }
const todayBerlin = () => berlinYmd(new Date())
export function offerPreviewText(price, months, regular, running) {
const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate`
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${formatCents(price)} statt ${formatCents(regular)}`
return `${lead} ${amount}, danach ${formatCents(regular)} pro Monat`
}
/** Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.) */
export function addMonthsYmd(ymd, n) {
const [y, m, d] = ymd.split('-').map(Number)
const last = new Date(Date.UTC(y, m + n, 0)).getUTCDate()
return new Date(Date.UTC(y, m - 1 + n, Math.min(d, last))).toISOString().slice(0, 10)
}
/** Rabattende eines noch nicht eingeloesten Angebots (Abschluss heute bis letzter Gueltigkeitstag) */
export function offerDiscountEstimate(months, validUntilYmd, testAccount = false, today = todayBerlin()) {
const n = months + (testAccount ? 1 : 0)
return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) }
}
export function discountLine(o, today = todayBerlin()) {
if (o.status === 'offen' && o.validUntil) {
const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount, today)
return earliest === latest
? `Rabatt endet voraussichtlich am ${fmtYmd(earliest)}`
: `Rabatt endet voraussichtlich zwischen ${fmtYmd(earliest)} und ${fmtYmd(latest)}`
}
if (o.status !== 'aktiv' || !o.discount) return ''
const d = o.discount
if (!d.known) return 'Rabattende unbekannt (Abo nicht gefunden)'
const end = d.subscriptionEndsAt || d.endsAt
const past = new Date(end) <= new Date()
return d.subscriptionEndsAt
? `Rabatt ${past ? 'endete' : 'endet'} mit dem Abo am ${fmtBerlin(end)}`
: `Rabatt ${past ? 'endete' : 'endet'} am ${fmtBerlin(end)}`
}
export const isRunning = (ctx, productKey) => Boolean(ctx?.subscription?.active && ctx.subscription.productKey === productKey)
/**
* Pruefung und Vorschau des Formulars "Neues Angebot" (admin.js renderOfferPreview).
* -> { errors, running, saveLabel, previewLines }; Speichern nur ohne errors.
*/
export function evaluateOfferForm(ctx, f, today = todayBerlin()) {
const regular = ctx.products[f.productKey].regularPriceCents
const running = isRunning(ctx, f.productKey)
const openOffer = ctx.openOffers[f.productKey]
const errors = []
if (openOffer) errors.push(`Es gibt schon ein offenes ${PRODUCT_LABEL[f.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
if (!Number.isInteger(f.monthlyPriceCents) || f.monthlyPriceCents < 0 || f.monthlyPriceCents >= regular) {
errors.push(`Angebotspreis bitte zwischen 0,00 € und ${formatCents(regular - 1)} angeben.`)
}
if (!f.validUntil && !running) errors.push('Bitte „Gültig bis“ angeben.')
const previewLines = []
if (!errors.length) {
previewLines.push(`${offerPreviewText(f.monthlyPriceCents, f.months, regular, running)} · Ersparnis ${formatCents((regular - f.monthlyPriceCents) * f.months)}`)
if (running) {
const charges = ctx.subscription.upcomingCharges || []
if (charges.length > f.months) previewLines.push(`Rabatt endet am ${fmtBerlin(charges[f.months])} (nach ${f.months === 1 ? '1 Abbuchung' : `${f.months} Abbuchungen`}).`)
} else {
previewLines.push(`Angebot gültig bis ${fmtYmd(f.validUntil)}`)
const { earliest, latest } = offerDiscountEstimate(f.months, f.validUntil, ctx.customer.testAccount, today)
const lead = `Rabatt läuft ${f.months === 1 ? '1 Monat' : `${f.months} Monate`} ab Abschluss und endet voraussichtlich`
previewLines.push(`${earliest === latest
? `${lead} am ${fmtYmd(earliest)}.`
: `${lead} zwischen ${fmtYmd(earliest)} (Abschluss heute) und ${fmtYmd(latest)} (Abschluss am letzten Tag).`} Bei Beginn nach der Widerrufsfrist gut 2 Wochen später.`)
}
}
previewLines.push(running
? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.`
: 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
if (!running && !ctx.customer.portalAccessEnabled) previewLines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
if (ctx.customer.testAccount) previewLines.push(`Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt${running ? '' : ' und gilt deshalb erst ab der zweiten Abbuchung'}.`)
return { errors, running, saveLabel: running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen', previewLines }
}
export function offerConfirm(ctx, f, running) {
const text = offerPreviewText(f.monthlyPriceCents, f.months, ctx.products[f.productKey].regularPriceCents, running)
return `Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(f.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`
}
export function createdMessage(result, notifyCustomer) {
const o = result.offer
const until = o.scope !== 'laufend'
? `, gültig bis ${fmtDate(o.validUntil)}`
: o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : ''
return [
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
notifyCustomer
? (result.mailSent
? `Die E-Mail an ${o.customerEmail} ist raus.`
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`)
: '',
].filter(Boolean).join(' ')
}
+39
View File
@@ -0,0 +1,39 @@
import { fmtDate } from './format'
// E-Mail-Speicher (Zusatzspeicher-Aufgaben in konsoleH): Texte wortgleich aus dem Admin-Panel (public/admin.js 908-982).
const GB_INCLUDED = 5
const GB_PER_UNIT = 5
const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0)
export function storageLabel(units) {
const n = Number(units) || 0
const packages = n === 0 ? 'kein Paket' : n === 1 ? '1 Paket' : `${n} Pakete`
return `${packages} (${gbOfUnits(n)} GB)`
}
export function summaryText({ allocatedGb, packageGb, warnGb, packageName }) {
const warn = Number(allocatedGb) >= Number(warnGb)
const head = `${allocatedGb} von ${packageGb} GB vergeben (Paket ${packageName})`
return { warn, text: warn ? `${head} – Warnschwelle ${warnGb} GB erreicht: größeres Hetzner-Paket buchen.` : `${head} · Warnung ab ${warnGb} GB` }
}
export const taskText = (t) => (t.kind === 'einrichten'
? `Postfachgrenze in konsoleH jetzt auf ${t.targetGb} GB setzen`
: `Postfachgrenze in konsoleH am ${fmtDate(t.dueAt)} auf ${t.targetGb} GB senken`)
export const scopeText = (t) =>
`Gesamtspeicher des Kunden: ${t.targetGb} GB, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${t.contactDomain}` : ''}`
export const changeText = (t) => `${storageLabel(t.fromUnits)} → ${storageLabel(t.toUnits)}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}`
export const partialText = (t) => (t.partial
? `Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${t.toUnits} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${t.error}` : ''}`
: '')
export const orderedKind = (t) => (t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')
export const isOverdue = (t, now = Date.now()) => Boolean(t.dueAt && new Date(t.dueAt).getTime() <= now)
export const failedText = (f) =>
`${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}`
+40
View File
@@ -59,3 +59,43 @@ export const chatUploadUrl = (customerId) => `${PORTAL_URL}/api/team/chats/${enc
export async function teamAuthHeaders() {
return { Authorization: `Bearer ${await jwtCache.get()}` }
}
// --- Gutschriften (Uebernahmegutschriften) ---
const query = (params) => {
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString()
return q ? `?${q}` : ''
}
/** { requests, openCount }; status 'open' (Standard) oder 'all', optional nur ein Kunde */
export const listCredits = ({ status = 'open', customerId } = {}) => request(`/takeover-credits${query({ status, customerId })}`)
export const getCreditOpenCount = () => request('/takeover-credits/open-count')
export const getCredit = (id) => request(`/takeover-credits/${enc(id)}`)
export const calcCredit = (body) => request('/takeover-credits/calc', { method: 'POST', body })
export const decideCredit = (id, body) => request(`/takeover-credits/${enc(id)}/decision`, { method: 'POST', body })
export const applyCredit = (id) => request(`/takeover-credits/${enc(id)}/apply`, { method: 'POST', body: {} })
// --- Abo-Angebote ---
export const listOffers = ({ customerId } = {}) => request(`/offers${query({ customerId })}`)
export const getOfferContext = (customerId) => request(`/offers/context/${enc(customerId)}`)
export const createOffer = (body) => request('/offers', { method: 'POST', body })
export const withdrawOffer = (id) => request(`/offers/${enc(id)}/withdraw`, { method: 'POST', body: {} })
export const notifyOffer = (id) => request(`/offers/${enc(id)}/notify`, { method: 'POST', body: {} })
// --- E-Mail-Speicher ---
export const listStorageTasks = () => request('/storage-tasks')
export const getStorageOpenCount = () => request('/storage-tasks/open-count')
export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`, { method: 'POST', body: {} })
// --- Funktionen (News-Pinnwand je Projekt) ---
export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`)
export const setNewsFeature = (customerId, { projectId, action, note }) =>
request(`/funktionen/${enc(customerId)}/news`, { method: 'POST', body: { projectId, action, note } })
// --- Abo-Status (alle Mitarbeiter) ---
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
export const getCustomerAboStatus = (customerId) => request(`/customers/${enc(customerId)}/abo`)
+15
View File
@@ -0,0 +1,15 @@
import { PageHeader } from '../components/ui'
import CreditsView from '../components/credits/CreditsView'
// Kunden -> Gutschriften: Antraege auf Uebernahmegutschrift aller Kunden
export default function CreditsPage() {
return (
<div className="page">
<PageHeader title="Gutschriften" subtitle="Übernahmegutschriften: bereits beim alten Anbieter bezahlte Leistungen verrechnen" />
<CreditsView />
<p className="area-muted" style={{ marginTop: 12 }}>
Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.
</p>
</div>
)
}
+12
View File
@@ -14,6 +14,10 @@ import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
import CustomerViewLog from '../components/customer/CustomerViewLog'
import ChatThread from '../components/chat/ChatThread'
import AboStatusCard from '../components/customer/AboStatusCard'
import CustomerFunktionen from '../components/customer/CustomerFunktionen'
import CreditsView from '../components/credits/CreditsView'
import OffersView from '../components/offers/OffersView'
import KundenansichtButton from '../components/customer/KundenansichtButton'
import { getCustomerPortalStatus } from '../lib/teamApi'
import { formatLastLogin } from '../lib/lastLogin'
@@ -106,6 +110,14 @@ export default function CustomerDetailPage() {
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
{activeTab === 'abo' && (
<>
<AboStatusCard customerId={customer.$id} />
<div style={{ marginTop: 16 }}><OffersView customerId={customer.$id} customerName={customer.name} /></div>
</>
)}
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
{activeTab === 'funktionen' && <CustomerFunktionen customerId={customer.$id} />}
{activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />}
</div>
)
+13
View File
@@ -0,0 +1,13 @@
import { PageHeader } from '../components/ui'
import OffersView from '../components/offers/OffersView'
// Kunden -> Abo-Angebote: individuelle Angebote aller Kunden (Stripe-Coupon je Angebot)
export default function OffersPage() {
return (
<div className="page">
<PageHeader title="Abo-Angebote" subtitle="Individuelle Angebote für das Wartungs- oder Hosting-Abo" />
<OffersView />
<p className="area-muted" style={{ marginTop: 12 }}>Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
import { PageHeader, Card, Badge, Button } from '../components/ui'
import { listStorageTasks, markStorageDone } from '../lib/teamApi'
import { fmtDate, fmtDateTime } from '../lib/format'
import { changeText, failedText, isOverdue, orderedKind, partialText, scopeText, summaryText, taskText } from '../lib/storageFormat'
import '../styles/admin-areas.css'
// E-Mail-Speicher (Kunden -> E-Mail-Speicher): Zusatzspeicher-Aufgaben in konsoleH wie im frueheren Admin-Panel
export default function StorageTasksPage() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [busyId, setBusyId] = useState('')
const load = useCallback(() => {
setError(null)
listStorageTasks()
.then(setData)
.catch((err) => setError(err.message || 'Speicher-Aufgaben konnten nicht geladen werden.'))
}, [])
useEffect(() => { load() }, [load])
const done = async (task) => {
if (task.partial && !window.confirm('Stripe-Menge von Hand korrigiert?')) return
setBusyId(task.id)
try {
await markStorageDone(task.id)
load()
} catch (err) {
window.alert(`Aufgabe konnte nicht als erledigt gespeichert werden: ${err.message}`)
} finally {
setBusyId('')
}
}
const summary = data ? summaryText(data) : null
return (
<div className="page">
<PageHeader title="E-Mail-Speicher" subtitle="Zusatzspeicher der Abos in konsoleH einrichten und senken" />
{error && (
<div className="area-error area-inline">
{error} <Button size="sm" variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
</div>
)}
{!data && !error && <FaSpinner className="spinner" />}
{data && (
<>
<div className={`area-summary ${summary.warn ? 'warn' : ''}`}>{summary.text}</div>
<Card pad={false}>
<div className="area-rows">
<div className="area-row storage-row head"><span>Fällig</span><span>Kunde</span><span>Aufgabe in konsoleH</span><span>Bestellt</span><span /></div>
{data.tasks.length === 0 && <div className="area-row"><span className="area-muted">Keine offenen Speicher-Aufgaben.</span></div>}
{data.tasks.map((t) => (
<div key={t.id} className="area-row storage-row">
<span>
{t.kind === 'einrichten'
? <><Badge tone="warn">jetzt einrichten</Badge><br /><span className="area-muted">bestellt {fmtDateTime(t.createdAt)}</span></>
: <><Badge tone={isOverdue(t) ? 'warn' : 'muted'}>zum Periodenende</Badge><br />{fmtDate(t.dueAt)}</>}
</span>
<span>
<Link to={`/customers/${t.customerId}`}><strong>{t.customerName}</strong></Link>
{t.customerCode && <><br /><span className="area-muted">Kd.-Nr. {t.customerCode}</span></>}
{t.email && <><br /><span className="area-muted">{t.email}</span></>}
<br /><span className="area-muted">{t.productLabel}</span>
</span>
<span>
<strong>{taskText(t)}</strong>
<br /><span className="area-muted">{scopeText(t)}</span>
<br /><span className="area-muted">{changeText(t)}</span>
{t.partial && <><br /><span className="area-danger">{partialText(t)}</span></>}
</span>
<span>{fmtDateTime(t.createdAt)}<br /><span className="area-muted">{orderedKind(t)}</span></span>
<span>
<Button size="sm" variant="primary" data-storage-done={t.id} onClick={() => done(t)} disabled={busyId === t.id}>
{busyId === t.id ? <FaSpinner className="spinner" /> : 'Erledigt'}
</Button>
</span>
</div>
))}
</div>
</Card>
{data.failed.length > 0 && (
<Card title="Gescheiterte Änderungen (letzte 30 Tage)">
{data.failed.map((f) => (
<div key={f.id} style={{ marginBottom: 6 }}>
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="area-muted">{failedText(f)}</span>
</div>
))}
</Card>
)}
<p className="area-muted" style={{ marginTop: 12 }}>
Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.
</p>
</>
)}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
/* Bereiche aus dem frueheren Admin-Panel (Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen, Abo-Status).
Zeilen als Raster; unter 720 px untereinander, damit nichts waagerecht scrollt. */
.area-rows { display: flex; flex-direction: column; container-type: inline-size; }
.area-row {
display: grid; gap: var(--sp-3); align-items: start; padding: 12px 16px;
border-bottom: 1px solid var(--border); overflow-wrap: anywhere;
}
.area-row:last-child { border-bottom: none; }
.area-row.head { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.area-row.active { background: var(--accent-soft); }
.area-muted { color: var(--text-muted); font-size: 13px; }
.area-danger { color: #f87171; font-size: 13px; }
.area-warn { color: #fbbf24; font-size: 13px; }
.area-summary { padding: 12px 16px; border-radius: var(--r-sm); background: var(--surface-2); margin-bottom: var(--sp-3); }
.area-summary.warn { background: rgba(245, 158, 11, 0.16); color: #fbbf24; }
.area-message { padding: 10px 14px; border-radius: var(--r-sm); background: var(--accent-soft); margin-bottom: var(--sp-3); }
.area-error { padding: 10px 14px; border-radius: var(--r-sm); background: rgba(239, 68, 68, 0.14); color: #f87171; margin: var(--sp-2) 0; }
.area-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
.area-kv dt { color: var(--text-muted); }
.area-kv dd { margin: 0; overflow-wrap: anywhere; }
.area-filter { display: inline-flex; gap: 4px; margin-bottom: var(--sp-3); }
.area-inline { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.storage-row { grid-template-columns: 150px 220px minmax(0, 1fr) 150px 110px; }
.funktionen-row { grid-template-columns: minmax(0, 1.2fr) 200px 110px minmax(0, 1.4fr); }
@media (max-width: 720px) {
.area-row { grid-template-columns: minmax(0, 1fr) !important; }
.area-row.head { display: none; }
.area-kv { grid-template-columns: minmax(0, 1fr); }
.area-kv dt { margin-top: 6px; }
}
.area-rows a { color: var(--text); text-decoration: none; }
.area-rows a:hover { color: var(--accent); text-decoration: underline; }
.credit-row { grid-template-columns: 130px minmax(0, 1fr) minmax(0, 1fr) 110px 100px 150px 100px; }
.credit-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-5); }
.credit-cols h4 { margin: var(--sp-4) 0 var(--sp-2); font-size: 14px; }
.credit-files, .credit-checks { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.credit-checks { list-style: none; padding-left: 0; }
.credit-history { margin-bottom: 8px; }
.credit-manual { display: flex; flex-direction: column; gap: 6px; }
.credit-calc { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); }
.credit-calc label, .credit-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); margin-bottom: var(--sp-2); }
@media (max-width: 900px) { .credit-cols { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 720px) { .credit-calc { grid-template-columns: minmax(0, 1fr); } }
.credit-cols a { color: var(--accent); }
.offer-row { grid-template-columns: 130px 200px minmax(0, 1fr) 110px 210px; }
/* Nach verfuegbarer Breite stapeln (nicht nach Fensterbreite): mit ausgeklappter Sidebar sind Tablet quer oder ein
halbes Laptop-Fenster sonst zu schmal fuer die Spalten */
@container (max-width: 960px) {
.area-row { grid-template-columns: minmax(0, 1fr) !important; gap: 4px; }
.area-row.head { display: none; }
}
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Etappe 3: Texte und Rechnungen aus dem Admin-Panel (public/admin.js), wortgleich ins Ticketsystem uebernommen
const fmt = await loadModule('src/lib/format.js')
const credit = await loadModule('src/lib/creditFormat.js')
const offer = await loadModule('src/lib/offerFormat.js')
const storage = await loadModule('src/lib/storageFormat.js')
const NB = ' '
test('format: Euro aus Cent, Eingabe in Cent, deutsche Daten', () => {
assert.equal(fmt.formatCents(123456), `1.234,56${NB}€`)
assert.equal(fmt.formatCents(0), `0,00${NB}€`)
assert.equal(fmt.parseEuroToCents('12,5'), 1250)
assert.equal(fmt.parseEuroToCents('12.50'), 1250)
assert.equal(fmt.parseEuroToCents(''), null)
assert.equal(fmt.parseEuroToCents('abc'), null)
assert.equal(fmt.fmtDate('2026-09-30T22:30:00.000Z'), '01.10.2026')
assert.equal(fmt.fmtDate(null), '–')
assert.equal(fmt.fmtDateTime('2026-09-30T10:05:00.000Z'), '30.09.26, 12:05')
assert.equal(fmt.fmtYmd('2026-10-27'), '27.10.2026')
})
test('Gutschrift: Status, Verlauf, Rechenzeile', () => {
assert.deepEqual(credit.CREDIT_STATUS.offen, { tone: 'warn', label: 'Zu prüfen' })
assert.deepEqual(credit.CREDIT_STATUS.rueckfrage, { tone: 'muted', label: 'Rückfrage beim Kunden' })
assert.equal(credit.historyLabel('verrechnet'), 'In Stripe gutgeschrieben')
assert.equal(credit.historyLabel('nachweise-geloescht'), 'Nachweise gelöscht')
assert.equal(credit.historyLabel('neu'), 'neu')
assert.deepEqual(credit.BILLING_MONTHS, [1, 3, 6, 12, 24, 36])
assert.equal(
credit.calcLine({ periodStart: '2026-01-01', periodEnd: '2026-12-31', periodDays: 365, overlapStart: '2026-10-01', overlapDays: 92, suggestedCents: 3025 }),
`Rechnungszeitraum 01.01.2026 – 31.12.2026 (365 Tage). Doppelt bezahlt ab 01.10.2026: 92 Tage → Vorschlag 30,25${NB}€`
)
})
test('Gutschrift: Pruefung, Rueckfragen und Meldungen wie im Admin-Panel', () => {
assert.equal(credit.decisionError('question', 'kurz'), 'Bitte die Rückfrage an den Kunden ins Nachrichtenfeld schreiben.')
assert.equal(credit.decisionError('reject', ''), 'Bitte die Begründung für den Kunden ins Nachrichtenfeld schreiben.')
assert.equal(credit.decisionError('question', 'Bitte Rechnung'), null)
assert.equal(credit.decisionError('approve', ''), null)
assert.equal(credit.decisionConfirm('approve', 3025, 'Berta'), `Gutschrift über 30,25${NB}€ genehmigen, in Stripe gutschreiben und Berta per E-Mail informieren?`)
assert.equal(credit.decisionConfirm('question', 0, ''), 'Rückfrage per E-Mail an den Kunden senden?')
assert.equal(credit.decisionConfirm('reject', 0, 'Berta'), 'Antrag ablehnen und Berta die Begründung per E-Mail senden?')
assert.equal(credit.decisionMessage('approve', { status: 'genehmigt', applied: true, mailSent: true }), 'Genehmigt. Das Guthaben ist in Stripe gebucht. Die E-Mail an den Kunden ist raus.')
assert.equal(
credit.decisionMessage('approve', { status: 'genehmigt', applied: false, reason: 'Stripe: kaputt', mailSent: false, mailError: 'SMTP weg' }),
'Genehmigt. Noch nicht in Stripe gebucht: Stripe: kaputt E-Mail an den Kunden fehlgeschlagen (SMTP weg) – bitte selbst informieren.'
)
assert.equal(credit.decisionMessage('question', { status: 'rueckfrage', mailSent: false }), 'Rückfrage gesendet. E-Mail an den Kunden fehlgeschlagen (unbekannt) – bitte selbst informieren.')
assert.equal(credit.applyMessage({ applied: true }), 'Das Guthaben ist in Stripe gebucht.')
assert.equal(credit.applyMessage({ applied: false, reason: 'Der Kunde hat kein laufendes Abo.' }), 'Nicht gebucht: Der Kunde hat kein laufendes Abo.')
})
test('Angebot: Vorschautext, Monate, Rabattende', () => {
assert.equal(offer.offerPreviewText(2500, 2, 5000, false), `Erste 2 Monate je 25,00${NB}€ statt 50,00${NB}€, danach 50,00${NB}€ pro Monat`)
assert.equal(offer.offerPreviewText(0, 1, 2000, true), `Nächster Monat kostenlos, danach 20,00${NB}€ pro Monat`)
assert.equal(offer.addMonthsYmd('2026-01-31', 1), '2026-02-28')
assert.equal(offer.addMonthsYmd('2026-11-15', 2), '2027-01-15')
const est = offer.offerDiscountEstimate(2, '2026-10-31', false, '2026-10-02')
assert.deepEqual(est, { earliest: '2026-12-02', latest: '2026-12-31' })
assert.deepEqual(offer.offerDiscountEstimate(2, '2026-10-31', true, '2026-10-02'), { earliest: '2027-01-02', latest: '2027-01-31' })
assert.equal(offer.discountLine({ status: 'offen', validUntil: '2026-10-02T00:00:00.000Z', months: 2 }, '2026-10-02'), 'Rabatt endet voraussichtlich am 02.12.2026')
assert.equal(offer.discountLine({ status: 'aktiv', discount: { known: false } }), 'Rabattende unbekannt (Abo nicht gefunden)')
assert.equal(offer.discountLine({ status: 'aktiv', discount: { known: true, endsAt: '2099-01-01T12:00:00.000Z' } }), 'Rabatt endet am 01.01.2099')
assert.deepEqual(offer.OFFER_STATUS.zurueckgezogen, { tone: 'muted', label: 'zurückgezogen' })
})
const ctx = (over = {}) => ({
customer: { id: 'c1', name: 'Berta', portalAccessEnabled: true, testAccount: false },
subscription: null,
openOffers: { hosting: null, wartung: null },
products: { hosting: { regularPriceCents: 2000 }, wartung: { regularPriceCents: 5000 } },
defaultValidUntil: '2026-11-01',
...over,
})
const form = (over = {}) => ({ customerId: 'c1', productKey: 'wartung', monthlyPriceCents: 2500, months: 2, validUntil: '2026-10-31', ...over })
test('Angebotsformular: Sperren, laufendes Abo, Knopftext', () => {
const ok = offer.evaluateOfferForm(ctx(), form(), '2026-10-02')
assert.deepEqual(ok.errors, [])
assert.equal(ok.running, false)
assert.equal(ok.saveLabel, 'Angebot anlegen')
assert.equal(ok.previewLines[0], `Erste 2 Monate je 25,00${NB}€ statt 50,00${NB}€, danach 50,00${NB}€ pro Monat · Ersparnis 50,00${NB}€`)
assert.equal(ok.previewLines.at(-1), 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
const blocked = offer.evaluateOfferForm(ctx({ openOffers: { hosting: null, wartung: { ref: 'A1', text: 'x' } } }), form({ monthlyPriceCents: 5000 }), '2026-10-02')
assert.deepEqual(blocked.errors, [
'Es gibt schon ein offenes Wartungs-Abo-Angebot (A1: x) – bitte zuerst zurückziehen.',
`Angebotspreis bitte zwischen 0,00 € und 49,99${NB}€ angeben.`,
])
const running = offer.evaluateOfferForm(ctx({ subscription: { active: true, productKey: 'wartung', currentPeriodEnd: '2026-11-01T00:00:00.000Z', upcomingCharges: ['2026-11-01T00:00:00.000Z', '2026-12-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'] } }), form({ validUntil: '' }), '2026-10-02')
assert.equal(running.running, true)
assert.equal(running.saveLabel, 'Angebot anlegen und aufs laufende Abo anwenden')
assert.ok(running.previewLines.includes('Rabatt endet am 01.01.2027 (nach 2 Abbuchungen).'))
assert.deepEqual(offer.evaluateOfferForm(ctx(), form({ validUntil: '' }), '2026-10-02').errors, ['Bitte „Gültig bis“ angeben.'])
assert.equal(
offer.offerConfirm(ctx(), form(), false),
`Angebot für Berta anlegen?\n\nErste 2 Monate je 25,00${NB}€ statt 50,00${NB}€, danach 50,00${NB}€ pro Monat\nGültig bis 31.10.2026\n\nDer Coupon wird sofort in Stripe angelegt.`
)
})
test('E-Mail-Speicher: Pakete, Zusammenfassung, Aufgaben', () => {
assert.equal(storage.storageLabel(0), 'kein Paket (5 GB)')
assert.equal(storage.storageLabel(1), '1 Paket (10 GB)')
assert.equal(storage.storageLabel(3), '3 Pakete (20 GB)')
assert.deepEqual(storage.summaryText({ allocatedGb: 20, packageGb: 50, warnGb: 40, packageName: 'Webhosting M' }), { warn: false, text: '20 von 50 GB vergeben (Paket Webhosting M) · Warnung ab 40 GB' })
assert.deepEqual(storage.summaryText({ allocatedGb: 41, packageGb: 50, warnGb: 40, packageName: 'Webhosting M' }), { warn: true, text: '41 von 50 GB vergeben (Paket Webhosting M) – Warnschwelle 40 GB erreicht: größeres Hetzner-Paket buchen.' })
assert.equal(storage.taskText({ kind: 'einrichten', targetGb: 15 }), 'Postfachgrenze in konsoleH jetzt auf 15 GB setzen')
assert.equal(storage.taskText({ kind: 'periodenende', targetGb: 10, dueAt: '2026-11-01T00:00:00.000Z' }), 'Postfachgrenze in konsoleH am 01.11.2026 auf 10 GB senken')
assert.equal(storage.scopeText({ targetGb: 15, contactDomain: 'kunde.de' }), 'Gesamtspeicher des Kunden: 15 GB, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4 · Kontakt: kunde.de')
assert.equal(storage.changeText({ fromUnits: 0, toUnits: 2, viaCancellation: true }), 'kein Paket (5 GB) → 2 Pakete (15 GB) · über die Kündigungsseite')
assert.equal(storage.partialText({ partial: true, toUnits: 1, error: 'x' }), 'Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf 1 Paket(en) – Stückzahl in Stripe von Hand nachziehen. Fehler: x')
assert.equal(storage.orderedKind({ fromUnits: 0, toUnits: 2 }), 'Bestellung (Plus)')
assert.equal(storage.orderedKind({ fromUnits: 3, toUnits: 1 }), 'Verringerung (Minus)')
assert.equal(storage.failedText({ fromUnits: 0, toUnits: 1, error: '' }), 'kein Paket (5 GB) → 1 Paket (10 GB) – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.')
})
+8 -4
View File
@@ -4,20 +4,23 @@ import { loadModule } from './load.mjs'
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
test('customerTabs: Rechnungen nur für Admins, Reihenfolge mit Chat und Protokoll, Übersicht mit Umlaut', () => {
test('customerTabs: volle Reiterfolge der Spec, Rechnungen nur für Admins', () => {
assert.deepEqual(customerTabs(true), [
{ id: 'overview', label: 'Übersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'projects', label: 'Projekte' },
{ id: 'chat', label: 'Chat' },
{ id: 'abo', label: 'Abo & Angebote' },
{ id: 'gutschriften', label: 'Gutschriften' },
{ id: 'funktionen', label: 'Funktionen' },
{ id: 'protokoll', label: 'Protokoll' },
])
const staff = customerTabs(false)
assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll'])
const STAFF = ['overview', 'tickets', 'projects', 'chat', 'abo', 'gutschriften', 'funktionen', 'protokoll']
assert.deepEqual(customerTabs(false).map((t) => t.id), STAFF)
// unbekannt oder nicht eindeutig true gilt als kein Admin
for (const v of [undefined, null, 0, '', 'true']) {
assert.deepEqual(customerTabs(v).map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll'], String(v))
assert.deepEqual(customerTabs(v).map((t) => t.id), STAFF, String(v))
}
// die Einträge tragen nur id und label (kein internes Feld)
assert.deepEqual(Object.keys(customerTabs(true)[2]), ['id', 'label'])
@@ -32,6 +35,7 @@ test('resolveCustomerTab: erlaubter Reiter bleibt, sonst Übersicht', () => {
assert.equal(resolveCustomerTab('overview', false), 'overview')
assert.equal(resolveCustomerTab('protokoll', false), 'protokoll')
assert.equal(resolveCustomerTab('chat', false), 'chat')
for (const id of ['abo', 'gutschriften', 'funktionen']) assert.equal(resolveCustomerTab(id, false), id)
assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview')
assert.equal(resolveCustomerTab(undefined, true), 'overview')
})
+10 -4
View File
@@ -5,15 +5,21 @@ import { loadModule } from './load.mjs'
// Sidebar: Eintraege als reine Daten (Icons ordnet Navbar.jsx ueber key zu)
const { buildNavLinks } = await loadModule('src/lib/navLinks.js')
test('buildNavLinks: Chat unter Kunden mit Zahl, Admin-Eintraege nur fuer Admins', () => {
const links = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', chatUnread: 3 })
test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen, Admin-Eintraege nur fuer Admins', () => {
const links = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', counts: { chat: 3, credits: 1, storage: 2 } })
const kunden = links.find((l) => l.key === 'customers')
assert.equal(kunden.href, '/customers')
assert.deepEqual(kunden.children, [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: 3 }])
assert.deepEqual(kunden.children.map((c) => [c.key, c.label, c.href, c.count]), [
['chat', 'Chat', '/customers/chat', 3],
['gutschriften', 'Gutschriften', '/customers/gutschriften', 1],
['angebote', 'Abo-Angebote', '/customers/angebote', undefined],
['speicher', 'E-Mail-Speicher', '/customers/email-speicher', 2],
])
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal'])
assert.equal(links.find((l) => l.key === 'leads').paused, true)
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', chatUnread: 0 })
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', counts: {} })
assert.deepEqual(admin.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'finance', 'legal', 'serverpuls', 'admin'])
assert.equal(admin.find((l) => l.key === 'serverpuls').dot, 'warn')
assert.equal(admin.find((l) => l.key === 'finance').also, '/finanzen')
assert.equal(admin.find((l) => l.key === 'customers').children[0].count, 0, 'ohne Zahl 0')
})