Abo-Angebote im Ticketsystem: Sammelliste, neues Angebot, Kunden-Reiter Abo & Angebote

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 03:05:12 +00:00
1 parent 109aef6805
commit 281c49d406
7 files changed
+321 -1

No files matched your search

+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>
)
}