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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:36:45 +00:00
1 parent b369a0edf9
commit 2f6437ee42
4 files changed
+77 -74

No files matched your search

+60 -56
View File
@@ -1,12 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6' import { Badge, Button, EmptyState, Tabs } from '../ui'
import { Card, Badge, Button } from '../ui'
import { listOffers, notifyOffer, withdrawOffer } from '../../lib/teamApi' 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 { 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 }) { export default function OfferList({ customerId, reloadKey, onMessage, onChanged }) {
const [filter, setFilter] = useState('open') const [filter, setFilter] = useState('open')
const [offers, setOffers] = useState(null) 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 ( return (
<div> <div>
<div className="area-filter seg"> <Tabs tabs={[{ id: 'open', label: 'Offen & aktiv' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen &amp; aktiv</button> {error && <div className="msg err" role="alert">{error}</div>}
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button> {!offers && !error && <div className="loading">Wird geladen …</div>}
</div> {offers && shown.length === 0 && (
<Card pad={false}> <EmptyState title={filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'} />
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>} )}
{!offers && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>} {offers && shown.length > 0 && (
{offers && ( <table className="table table-wrap">
<div className="area-rows"> <thead>
<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> <tr>
{shown.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</span></div>} <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) => { {shown.map((o) => {
const st = offerStatus(o.status) const st = offerStatus(o.status)
const discount = discountLine(o) const discount = discountLine(o)
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : '' 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 ( return (
<div key={o.id} className="area-row offer-row"> <tr key={o.id}>
<span>{fmtDateTime(o.createdAt)}<br /><span className="area-muted">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</span></span> <td className="hide-md">
<span> {fmtDateTime(o.createdAt)}
<strong>{o.customerName}</strong> <div className="cell-sub">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</div>
{o.customerEmail && <><br /><span className="area-muted">{o.customerEmail}</span></>} </td>
{o.customerCode && <><br /><span className="area-muted">Kd.-Nr. {o.customerCode}</span></>} <td>
</span> <div className="cell-title">{o.customerName}</div>
<span> {o.customerEmail && <div className="cell-sub">{o.customerEmail}</div>}
<span className="area-muted">{productLabelOf(o.productKey)}</span><br />{o.text} {o.customerCode && <div className="cell-sub">Kd.-Nr. {o.customerCode}</div>}
<br /><span className="area-muted">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</span> </td>
{discount && <><br /><span className="area-muted">{discount}</span></>} <td>
{o.internalNote && <><br /><span className="area-muted"><em>intern: {o.internalNote}</em></span></>} <div className="cell-sub">{productLabelOf(o.productKey)}</div>
</span> {o.text}
<span>{o.scope === 'laufend' ? <span className="area-muted">laufendes Abo</span> : fmtDate(o.validUntil)}</span> <div className="cell-sub">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</div>
<span> {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> <Badge tone={st.tone}>{st.label}</Badge>
{sub && <><br /><span className="area-muted">{sub}</span></>} {sub && <div className="cell-sub">{sub}</div>}
<br /><span className="area-muted">Stripe:</span>{' '} <div className="cell-sub">
{o.stripeCouponUrl ? <a href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'} Stripe: {o.stripeCouponUrl ? <a className="link" href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
{o.stripeSubscriptionUrl && <> · <a href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>} {o.stripeSubscriptionUrl && <> · <a className="link" href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
{o.notifiedAt </div>
? <><br /><span className="area-muted">E-Mail gesendet {fmtDateTime(o.notifiedAt)}</span></> {mail && <div className="cell-sub">{mail.tone === 'warn' ? <span className="msg warn">{mail.text}</span> : mail.text}</div>}
: o.mailSendable {(sendLabel || canWithdraw) && (
? <><br /><span className="area-warn">E-Mail nicht verschickt</span></> <div className="cell-actions">
: o.status === 'offen' && !o.customerEmail ? <><br /><span className="area-warn">Keine E-Mail-Adresse hinterlegt</span></> : null} {sendLabel && (
{(['offen', 'abgelaufen'].includes(o.status) || o.mailSendable) && ( <button type="button" className="link" title={`Angebots-E-Mail an ${o.customerEmail} senden`} onClick={() => sendMail(o)} disabled={busyId === o.id}>{sendLabel}</button>
<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) && ( {canWithdraw && <Button variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>}
<Button size="sm" variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button> </div>
)}
</span>
)} )}
</span> </td>
</div> </tr>
) )
})} })}
</div> </tbody>
)} </table>
</Card> )}
</div> </div>
) )
} }
+12 -17
View File
@@ -1,5 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { FaPlus } from 'react-icons/fa6'
import { Button } from '../ui' import { Button } from '../ui'
import OfferList from './OfferList' import OfferList from './OfferList'
import OfferForm from './OfferForm' import OfferForm from './OfferForm'
@@ -13,23 +12,19 @@ export default function OffersView({ customerId, customerName }) {
return ( return (
<div> <div>
{message && <div className="area-message">{message}</div>} {message && <p className="msg ok mb-2" role="status">{message}</p>}
{!showForm && ( <div className="toolbar">
<div style={{ marginBottom: 12 }}> <Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}>Neues Angebot</Button>
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}><FaPlus /> Neues Angebot</Button> </div>
</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} /> <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> </div>
) )
} }
+1 -1
View File
@@ -7,7 +7,7 @@ export default function OffersPage() {
<div className="page"> <div className="page">
<PageHeader title="Abo-Angebote" subtitle="Individuelle Angebote für das Wartungs- oder Hosting-Abo" /> <PageHeader title="Abo-Angebote" subtitle="Individuelle Angebote für das Wartungs- oder Hosting-Abo" />
<OffersView /> <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> <p className="table-foot">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> </div>
) )
} }
+4
View File
@@ -1216,6 +1216,10 @@ div.cell-actions { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6p
/* Der Abstand kommt vom Container, nicht vom Altabstand .page .field (KOMPAT, faellt in P10); die zweite Regel nimmt ihn dort weg. */ /* Der Abstand kommt vom Container, nicht vom Altabstand .page .field (KOMPAT, faellt in P10); die zweite Regel nimmt ihn dort weg. */
.form-stack { display: flex; flex-direction: column; gap: 16px; } .form-stack { display: flex; flex-direction: column; gap: 16px; }
.page .form-stack > .field { margin-bottom: 0; } .page .form-stack > .field { margin-bottom: 0; }
/* Angebote: die Statusspalte (Badge, Stripe-Links, Mail-Zeile, Aktionen) wird nicht schmaler als ganze Woerter; ohne Mindestbreite */
/* gibt ihr die Tabelle (td bricht ueberall) fast nichts ab und "Abschluss" oder "Zurueckziehen" brechen mitten im Wort. */
.table-wrap th.col-status { min-width: 7rem; }
@media (min-width: 861px) { .table-wrap th.col-status { min-width: 12rem; } }
/* ===== ENDE Kunden-Unterseiten (P6) ===== */ /* ===== ENDE Kunden-Unterseiten (P6) ===== */
/* ===== KOMPAT (bis P10) ===== */ /* ===== KOMPAT (bis P10) ===== */