From 2f6437ee424802243e9ce4dfa03308e2a12507e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 12:02:13 +0000 Subject: [PATCH] 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 --- src/components/offers/OfferList.jsx | 116 ++++++++++++++------------- src/components/offers/OffersView.jsx | 29 +++---- src/pages/OffersPage.jsx | 2 +- src/styles/global.css | 4 + 4 files changed, 77 insertions(+), 74 deletions(-) diff --git a/src/components/offers/OfferList.jsx b/src/components/offers/OfferList.jsx index 9154d57..4b6ea5d 100644 --- a/src/components/offers/OfferList.jsx +++ b/src/components/offers/OfferList.jsx @@ -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 (
-
- - -
- - {error &&
{error}
} - {!offers && !error &&
} - {offers && ( -
-
AngelegtKundeAngebotGültig bisStatus
- {shown.length === 0 &&
{filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}
} + + {error &&
{error}
} + {!offers && !error &&
Wird geladen …
} + {offers && shown.length === 0 && ( + + )} + {offers && shown.length > 0 && ( + + + + + + + + + + + {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 ( -
- {fmtDateTime(o.createdAt)}
{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}
- - {o.customerName} - {o.customerEmail && <>
{o.customerEmail}} - {o.customerCode && <>
Kd.-Nr. {o.customerCode}} -
- - {productLabelOf(o.productKey)}
{o.text} -
Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''} - {discount && <>
{discount}} - {o.internalNote && <>
intern: {o.internalNote}} -
- {o.scope === 'laufend' ? laufendes Abo : fmtDate(o.validUntil)} - +
+ + + + + + ) })} - - )} - + +
AngelegtKundeAngebotGültig bisStatus
+ {fmtDateTime(o.createdAt)} +
{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}
+
+
{o.customerName}
+ {o.customerEmail &&
{o.customerEmail}
} + {o.customerCode &&
Kd.-Nr. {o.customerCode}
} +
+
{productLabelOf(o.productKey)}
+ {o.text} +
Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}
+ {discount &&
{discount}
} + {o.internalNote &&
intern: {o.internalNote}
} +
{o.scope === 'laufend' ? laufendes Abo : fmtDate(o.validUntil)} {st.label} - {sub && <>
{sub}} -
Stripe:{' '} - {o.stripeCouponUrl ? Coupon : '–'} - {o.stripeSubscriptionUrl && <> · Abo} - {o.notifiedAt - ? <>
E-Mail gesendet {fmtDateTime(o.notifiedAt)} - : o.mailSendable - ? <>
E-Mail nicht verschickt - : o.status === 'offen' && !o.customerEmail ? <>
Keine E-Mail-Adresse hinterlegt : null} - {(['offen', 'abgelaufen'].includes(o.status) || o.mailSendable) && ( - - {o.mailSendable && ( - + {sub &&
{sub}
} +
+ Stripe: {o.stripeCouponUrl ? Coupon : '–'} + {o.stripeSubscriptionUrl && <> · Abo} +
+ {mail &&
{mail.tone === 'warn' ? {mail.text} : mail.text}
} + {(sendLabel || canWithdraw) && ( +
+ {sendLabel && ( + )} - {['offen', 'abgelaufen'].includes(o.status) && ( - - )} - + {canWithdraw && } +
)} -
- +
+ )}
) } diff --git a/src/components/offers/OffersView.jsx b/src/components/offers/OffersView.jsx index 18252cb..82fb12b 100644 --- a/src/components/offers/OffersView.jsx +++ b/src/components/offers/OffersView.jsx @@ -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 (
- {message &&
{message}
} - {!showForm && ( -
- -
- )} - {showForm && ( -
- { setMessage(text); setShowForm(false); reload() }} - onCancel={() => setShowForm(false)} - /> -
- )} + {message &&

{message}

} +
+ +
+ {showForm && ( + { setMessage(text); setShowForm(false); reload() }} + onCancel={() => setShowForm(false)} + /> + )}
) } diff --git a/src/pages/OffersPage.jsx b/src/pages/OffersPage.jsx index 1ccf6f8..03345b8 100644 --- a/src/pages/OffersPage.jsx +++ b/src/pages/OffersPage.jsx @@ -7,7 +7,7 @@ export default function OffersPage() {
-

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.

+

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.

) } diff --git a/src/styles/global.css b/src/styles/global.css index ed41cf9..e9cd89e 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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. */ .form-stack { display: flex; flex-direction: column; gap: 16px; } .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) ===== */ /* ===== KOMPAT (bis P10) ===== */