Design P6: Angebote-Liste als Tabelle mit Reitern, Aktionen als Textlinks
Reiter statt Filterknoepfe, Statusspalte mit Badge, Stripe-Links, Mail-Zeile und Aktionen (E-Mail senden als Textlink, Zurueckziehen als danger), Spalten Angelegt und Gueltig bis nach Breite ausgeblendet. Mindestbreite der Statusspalte, damit Woerter nicht mitten im Wort brechen. Neues Angebot ohne Icon in der Werkzeugleiste, Hinweis als table-foot. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b369a0edf9
commit
2f6437ee42
4 files changed
+77
-74
No files matched your search
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button } from '../ui'
|
||||
import { Badge, Button, EmptyState, Tabs } from '../ui'
|
||||
import { listOffers, notifyOffer, withdrawOffer } from '../../lib/teamApi'
|
||||
import { discountLine, offerStatus } from '../../lib/offerFormat'
|
||||
import { discountLine, filterOffers, offerMailLine, offerRowActions, 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
|
||||
// Liste der Abo-Angebote (Reiter "Offen & aktiv"/"Alle") als Tabelle; optional nur ein Kunde
|
||||
export default function OfferList({ customerId, reloadKey, onMessage, onChanged }) {
|
||||
const [filter, setFilter] = useState('open')
|
||||
const [offers, setOffers] = useState(null)
|
||||
@@ -51,69 +49,75 @@ export default function OfferList({ customerId, reloadKey, onMessage, onChanged
|
||||
}
|
||||
}
|
||||
|
||||
const shown = (offers || []).filter((o) => filter === 'all' || ['offen', 'aktiv'].includes(o.status))
|
||||
const shown = filterOffers(offers, filter)
|
||||
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>}
|
||||
<Tabs tabs={[{ id: 'open', label: 'Offen & aktiv' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
{!offers && !error && <div className="loading">Wird geladen …</div>}
|
||||
{offers && shown.length === 0 && (
|
||||
<EmptyState title={filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'} />
|
||||
)}
|
||||
{offers && shown.length > 0 && (
|
||||
<table className="table table-wrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="hide-md">Angelegt</th>
|
||||
<th>Kunde</th>
|
||||
<th>Angebot</th>
|
||||
<th className="hide-sm">Gültig bis</th>
|
||||
<th className="col-status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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' : ''
|
||||
const mail = offerMailLine(o)
|
||||
const { sendLabel, canWithdraw } = offerRowActions(o)
|
||||
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>
|
||||
<tr key={o.id}>
|
||||
<td className="hide-md">
|
||||
{fmtDateTime(o.createdAt)}
|
||||
<div className="cell-sub">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-title">{o.customerName}</div>
|
||||
{o.customerEmail && <div className="cell-sub">{o.customerEmail}</div>}
|
||||
{o.customerCode && <div className="cell-sub">Kd.-Nr. {o.customerCode}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-sub">{productLabelOf(o.productKey)}</div>
|
||||
{o.text}
|
||||
<div className="cell-sub">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</div>
|
||||
{discount && <div className="cell-sub">{discount}</div>}
|
||||
{o.internalNote && <div className="cell-sub"><em>intern: {o.internalNote}</em></div>}
|
||||
</td>
|
||||
<td className="hide-sm nowrap">{o.scope === 'laufend' ? <span className="muted">laufendes Abo</span> : fmtDate(o.validUntil)}</td>
|
||||
<td>
|
||||
<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>
|
||||
{sub && <div className="cell-sub">{sub}</div>}
|
||||
<div className="cell-sub">
|
||||
Stripe: {o.stripeCouponUrl ? <a className="link" href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
|
||||
{o.stripeSubscriptionUrl && <> · <a className="link" href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
|
||||
</div>
|
||||
{mail && <div className="cell-sub">{mail.tone === 'warn' ? <span className="msg warn">{mail.text}</span> : mail.text}</div>}
|
||||
{(sendLabel || canWithdraw) && (
|
||||
<div className="cell-actions">
|
||||
{sendLabel && (
|
||||
<button type="button" className="link" title={`Angebots-E-Mail an ${o.customerEmail} senden`} onClick={() => sendMail(o)} disabled={busyId === o.id}>{sendLabel}</button>
|
||||
)}
|
||||
{['offen', 'abgelaufen'].includes(o.status) && (
|
||||
<Button size="sm" variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>
|
||||
)}
|
||||
</span>
|
||||
{canWithdraw && <Button variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { FaPlus } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import OfferList from './OfferList'
|
||||
import OfferForm from './OfferForm'
|
||||
@@ -13,23 +12,19 @@ export default function OffersView({ customerId, customerName }) {
|
||||
|
||||
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>
|
||||
)}
|
||||
{message && <p className="msg ok mb-2" role="status">{message}</p>}
|
||||
<div className="toolbar">
|
||||
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}>Neues Angebot</Button>
|
||||
</div>
|
||||
<OfferList customerId={customerId} reloadKey={reloadKey} onMessage={setMessage} onChanged={reload} />
|
||||
{showForm && (
|
||||
<OfferForm
|
||||
fixedCustomerId={customerId}
|
||||
fixedCustomerName={customerName}
|
||||
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user