From 109aef68050736a2ca889d7eb679a91d801a62e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:02:24 +0000 Subject: [PATCH] Gutschriften im Ticketsystem: Sammelliste, Pruefung und Entscheidung, Kunden-Reiter Co-Authored-By: Claude Opus 5.5 --- src/App.jsx | 2 + src/components/credits/CreditDetail.jsx | 285 ++++++++++++++++++ src/components/credits/CreditList.jsx | 63 ++++ src/components/credits/CreditsView.jsx | 33 ++ .../customer/KundenansichtButton.jsx | 9 +- src/pages/CreditsPage.jsx | 15 + src/pages/CustomerDetailPage.jsx | 2 + src/styles/admin-areas.css | 12 + 8 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 src/components/credits/CreditDetail.jsx create mode 100644 src/components/credits/CreditList.jsx create mode 100644 src/components/credits/CreditsView.jsx create mode 100644 src/pages/CreditsPage.jsx diff --git a/src/App.jsx b/src/App.jsx index 5afa954..f656af3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ 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 FinancePage from './pages/FinancePage' import FinanceSettingsPage from './pages/FinanceSettingsPage' import LegalPage from './pages/LegalPage' @@ -54,6 +55,7 @@ function AppRoutes() { {/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */} } /> } /> + } /> } /> } /> {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} diff --git a/src/components/credits/CreditDetail.jsx b/src/components/credits/CreditDetail.jsx new file mode 100644 index 0000000..a187bb4 --- /dev/null +++ b/src/components/credits/CreditDetail.jsx @@ -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
{error}
+ if (!d || !form) return + + 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 ( + +
+
+

Antrag {r.ref} · {c.name || c.companyName}

+ eingegangen {fmtDateTime(r.createdAt)} {st.label} +
+ +
+ {message &&
{message}
} +
+
+

Antrag

+
+
Leistung
{r.services}
+
Bisher bei
{r.provider}
+
Domain
{r.domain ? {r.domain} : '–'}
+
Rechnung
{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}
+
Bezahlt bis
{fmtDate(r.paidUntil)}
+
Antwort an
{r.email}
+
Bestätigt
Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}
+
+

Grund / Nachricht

+

{r.message}

+

Nachweise

+ {r.files.length ? ( +
    + {r.files.map((f) => ( +
  • + {f.name} ({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)}) +
  • + ))} +
+ ) :

Keine Nachweise (mehr) vorhanden.

} +

Verlauf

+ {r.history.map((h, i) => ( +
+ {historyLabel(h.type)} {fmtDateTime(h.at)}{staffOf(h.by)} + {h.text &&
{h.text}
} + {(h.files || []).length > 0 &&
Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}
} +
+ ))} +
+
+

Kunde & Vertrag

+
+
Kunde
{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}
+
E-Mail
{c.email || '–'}
+
Abo
+
+ {s ? <>{productLabelOf(s.productKey)} {s.status}
Zusatzspeicher: {storageLabel(s.storageUnits)} : kein Abo} +
+ {s && ( + <> +
Leistungsbeginn
{fmtDate(s.serviceStart)}
+
Nächste Abbuchung
{s.active ? fmtDate(s.currentPeriodEnd) : '–'}
+ {s.cancelAt && <>
Gekündigt zum
{fmtDate(s.cancelAt)}
} +
Stripe-Guthaben
{s.creditBalanceCents == null ? '–' : formatCents(s.creditBalanceCents)}
+ + )} +
+
+ + {s?.stripeUrl && } +
+ {d.otherRequests.length > 0 && ( + <> +

Weitere Anträge des Kunden

+
    + {d.otherRequests.map((o) => { + const ost = creditStatus(o.status) + return ( +
  • + { e.preventDefault(); onOpen?.(o.id) }}>{o.ref} · {o.services} · {fmtDate(o.createdAt)} {ost.label}{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''} +
  • + ) + })} +
+ + )} +

Automatische Prüfung

+
    + {d.autoChecks.map((k) => ( +
  • {k.status === 'ok' ? '✓' : k.status === 'warn' ? '!' : 'i'} {k.label}{k.detail && <>
    {k.detail}}
  • + ))} +
+ + {pending ? ( + <> +

Prüfung durch dich

+
+ {d.requiredChecks.map((k) => ( + + ))} +
+

Berechnung

+
+ + + + +
+
{calcText}
+

Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.

+ +