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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user