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:
1 parent
109aef6805
commit
281c49d406
7 files changed
+321
-1
No files matched your search
@@ -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() {
|
||||
<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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' && <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} />}
|
||||
{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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user