From 281c49d406ada61a0db0ed8a4b39c939347f26f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:05:12 +0000 Subject: [PATCH] Abo-Angebote im Ticketsystem: Sammelliste, neues Angebot, Kunden-Reiter Abo & Angebote Co-Authored-By: Claude Opus 5.5 --- src/App.jsx | 2 + src/components/offers/OfferForm.jsx | 144 +++++++++++++++++++++++++++ src/components/offers/OfferList.jsx | 119 ++++++++++++++++++++++ src/components/offers/OffersView.jsx | 35 +++++++ src/pages/CustomerDetailPage.jsx | 8 +- src/pages/OffersPage.jsx | 13 +++ src/styles/admin-areas.css | 1 + 7 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/components/offers/OfferForm.jsx create mode 100644 src/components/offers/OfferList.jsx create mode 100644 src/components/offers/OffersView.jsx create mode 100644 src/pages/OffersPage.jsx diff --git a/src/App.jsx b/src/App.jsx index f656af3..e0505ce 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ 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' @@ -56,6 +57,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} diff --git a/src/components/offers/OfferForm.jsx b/src/components/offers/OfferForm.jsx new file mode 100644 index 0000000..d48cf3c --- /dev/null +++ b/src/components/offers/OfferForm.jsx @@ -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 =
Bitte einen Kunden aus der Liste wählen.
+ else if (ctx?.error && ctx.customerId === customerId) previewBox =
{ctx.error}
+ else if (!ready) previewBox =
Lade Kunde…
+ else { + previewBox = ( + <> + {evaluation.errors.map((l) =>
{l}
)} + {evaluation.previewLines.map((l) =>
{l}
)} + + ) + } + + return ( + +

Neues Angebot

+
+ + + + + +
+ + + +
{previewBox}
+
+ + +
+ {error &&
{error}
} +
+ ) +} diff --git a/src/components/offers/OfferList.jsx b/src/components/offers/OfferList.jsx new file mode 100644 index 0000000..9154d57 --- /dev/null +++ b/src/components/offers/OfferList.jsx @@ -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 ( +
+
+ + +
+ + {error &&
{error}
} + {!offers && !error &&
} + {offers && ( +
+
AngelegtKundeAngebotGültig bisStatus
+ {shown.length === 0 &&
{filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}
} + {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 ( +
+ {fmtDateTime(o.createdAt)}
{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}
+ + {o.customerName} + {o.customerEmail && <>
{o.customerEmail}} + {o.customerCode && <>
Kd.-Nr. {o.customerCode}} +
+ + {productLabelOf(o.productKey)}
{o.text} +
Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''} + {discount && <>
{discount}} + {o.internalNote && <>
intern: {o.internalNote}} +
+ {o.scope === 'laufend' ? laufendes Abo : fmtDate(o.validUntil)} + + {st.label} + {sub && <>
{sub}} +
Stripe:{' '} + {o.stripeCouponUrl ? Coupon : '–'} + {o.stripeSubscriptionUrl && <> · Abo} + {o.notifiedAt + ? <>
E-Mail gesendet {fmtDateTime(o.notifiedAt)} + : o.mailSendable + ? <>
E-Mail nicht verschickt + : o.status === 'offen' && !o.customerEmail ? <>
Keine E-Mail-Adresse hinterlegt : null} + {(['offen', 'abgelaufen'].includes(o.status) || o.mailSendable) && ( + + {o.mailSendable && ( + + )} + {['offen', 'abgelaufen'].includes(o.status) && ( + + )} + + )} +
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/src/components/offers/OffersView.jsx b/src/components/offers/OffersView.jsx new file mode 100644 index 0000000..18252cb --- /dev/null +++ b/src/components/offers/OffersView.jsx @@ -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 ( +
+ {message &&
{message}
} + {!showForm && ( +
+ +
+ )} + {showForm && ( +
+ { setMessage(text); setShowForm(false); reload() }} + onCancel={() => setShowForm(false)} + /> +
+ )} + +
+ ) +} diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx index c9f7653..ffde045 100644 --- a/src/pages/CustomerDetailPage.jsx +++ b/src/pages/CustomerDetailPage.jsx @@ -17,6 +17,7 @@ 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' @@ -109,7 +110,12 @@ export default function CustomerDetailPage() { {activeTab === 'invoices' && setTab('overview')} />} {activeTab === 'projects' && } {activeTab === 'chat' && } - {activeTab === 'abo' && } + {activeTab === 'abo' && ( + <> + +
+ + )} {activeTab === 'gutschriften' && } {activeTab === 'funktionen' && } {activeTab === 'protokoll' && } diff --git a/src/pages/OffersPage.jsx b/src/pages/OffersPage.jsx new file mode 100644 index 0000000..1ccf6f8 --- /dev/null +++ b/src/pages/OffersPage.jsx @@ -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 ( +
+ + +

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.

+
+ ) +} diff --git a/src/styles/admin-areas.css b/src/styles/admin-areas.css index ceea5df..f561210 100644 --- a/src/styles/admin-areas.css +++ b/src/styles/admin-areas.css @@ -42,3 +42,4 @@ @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; }