feat: Patenschafts-Honig automatisch waehlen (nie cremig, neueste Ernte)
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m18s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m32s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m41s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 20m38s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m18s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m32s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m41s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 20m38s
Pakete, Abo-Reservierung und Abo-Lieferungen nehmen immer den aktiven, nicht cremigen Honig der neuesten Ernte mit genug freiem Bestand, bei gleichem Jahrgang den mit dem meisten Bestand. Feste Honig-Zuordnung im Admin entfaellt (Spalte bleibt, wird nicht mehr genutzt). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
3ddf0bfa73
commit
1d88bac52e
30 files changed
+1152
-394
No files matched your search
@@ -6,6 +6,10 @@ import { logAdminAction } from "@/lib/audit-log";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||
import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||
import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-shared";
|
||||
|
||||
@@ -35,6 +39,12 @@ function carrierProductData(values: OfferFormValues) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* honeyProductId wird bewusst nicht geschrieben: der Honig wird automatisch
|
||||
* gewaehlt (lib/sponsorship/honey-selection.ts). Die Spalte bleibt nur im
|
||||
* Schema stehen, damit die laufende Produktion keine Migration braucht; ein
|
||||
* dort noch stehender Altwert wird nirgends mehr gelesen.
|
||||
*/
|
||||
function offerData(values: OfferFormValues) {
|
||||
return {
|
||||
slug: values.slug,
|
||||
@@ -48,7 +58,6 @@ function offerData(values: OfferFormValues) {
|
||||
isActive: values.isActive,
|
||||
sortOrder: values.sortOrder,
|
||||
isPhysical: values.isPhysical,
|
||||
honeyProductId: values.honeyProductId,
|
||||
honeyJars: values.honeyJars,
|
||||
honeyShareCentsPerJar: values.honeyShareCentsPerJar,
|
||||
honeyShareCentsPerMonth: values.honeyShareCentsPerMonth,
|
||||
@@ -59,10 +68,9 @@ function offerData(values: OfferFormValues) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefungen gegen die Datenbank: eindeutige Adressen, ein echtes
|
||||
* Honigprodukt — und die Aktivierungssperre: ein Angebot mit Honig laesst
|
||||
* sich nur aktivieren, wenn das Honigprodukt existiert und aktiv ist. Sonst
|
||||
* bekaeme die Kundschaft eine Urkunde ohne den bezahlten Honig.
|
||||
* Pruefungen gegen die Datenbank: eindeutige Adressen. Den Honig prueft
|
||||
* honeyWarning() — er sperrt das Aktivieren nicht mehr, weil er sich mit
|
||||
* jeder Bestellung und jeder neuen Ernte aendert.
|
||||
*/
|
||||
async function findConflict(
|
||||
values: OfferFormValues,
|
||||
@@ -77,19 +85,6 @@ async function findConflict(
|
||||
return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (values.honeyProductId) {
|
||||
const honey = await prisma.product.findUnique({
|
||||
where: { id: values.honeyProductId },
|
||||
select: { category: true, isActive: true, name: true },
|
||||
});
|
||||
if (!honey || honey.category !== "HONIG") return "Bitte ein Honigprodukt aus der Liste wählen.";
|
||||
if (values.isActive && !honey.isActive) {
|
||||
return `Das Angebot kann nicht aktiviert werden: das Honigprodukt "${honey.name}" ist inaktiv.`;
|
||||
}
|
||||
} else if (values.isActive && (values.isPhysical || values.honeyJars > 0)) {
|
||||
return "Ein physisches Angebot ohne Honigprodukt kann nicht aktiviert werden.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -98,7 +93,21 @@ function auditDetail(values: OfferFormValues): string {
|
||||
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`;
|
||||
}
|
||||
|
||||
/** Nach dem Speichern eines Abo-Angebots: Stripe-Preis nachziehen und passend melden. */
|
||||
/**
|
||||
* Aktivierungsregel fuer Angebote mit Honig: es muss einen Honig geben, den
|
||||
* die automatische Wahl nehmen darf (nicht cremig, genug freier Bestand fuer
|
||||
* die Glaeser). Fehlt er, wird trotzdem gespeichert — das Angebot steht dann
|
||||
* auf der Verkaufsseite als nicht bestellbar (isOfferAvailable), bis
|
||||
* fluessiger Honig nachgelegt ist —, aber der Admin bekommt einen Hinweis,
|
||||
* statt sich ueber ein "ausverkauftes" neues Angebot zu wundern.
|
||||
*/
|
||||
async function honeyWarning(values: OfferFormValues): Promise<string | null> {
|
||||
if (!values.isActive || values.honeyJars <= 0) return null;
|
||||
if (pickHoneyProduct(await loadHoneyCandidates(), values.honeyJars)) return null;
|
||||
return "Hinweis: Derzeit gibt es keinen nicht-cremigen Honig mit genug freiem Bestand – bestellbar erst, wenn flüssiger Honig nachgelegt ist.";
|
||||
}
|
||||
|
||||
/** Nach dem Speichern: Stripe-Preis eines Abo-Angebots nachziehen, Honig pruefen und passend melden. */
|
||||
async function finishSave(offerId: string, values: OfferFormValues, message: string): Promise<never> {
|
||||
revalidatePath(OFFERS_PATH);
|
||||
revalidatePath("/patenschaft");
|
||||
@@ -109,6 +118,11 @@ async function finishSave(offerId: string, values: OfferFormValues, message: str
|
||||
return fail(target, `${message} Der Stripe-Preis konnte aber nicht angelegt werden (${stripeError}). Bitte später erneut speichern.`);
|
||||
}
|
||||
}
|
||||
const warning = await honeyWarning(values);
|
||||
if (warning) {
|
||||
await setFlash("info", `${message} ${warning}`);
|
||||
redirect(target);
|
||||
}
|
||||
return succeed(target, message);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface OfferFormDefaults {
|
||||
isActive: boolean;
|
||||
isPhysical: boolean;
|
||||
sortOrder: number;
|
||||
honeyProductId: string | null;
|
||||
honeyJars: number;
|
||||
cycle: SponsorshipCycle | null;
|
||||
maxActiveSubscriptions: number | null;
|
||||
@@ -51,7 +50,6 @@ export const EMPTY_OFFER: OfferFormDefaults = {
|
||||
isActive: false,
|
||||
isPhysical: false,
|
||||
sortOrder: 0,
|
||||
honeyProductId: null,
|
||||
honeyJars: 0,
|
||||
cycle: null,
|
||||
maxActiveSubscriptions: null,
|
||||
@@ -62,7 +60,8 @@ interface OfferFormProps {
|
||||
defaults: OfferFormDefaults;
|
||||
/** Beim Bearbeiten ist die Art fest. */
|
||||
isNew: boolean;
|
||||
honeyOptions: { id: string; name: string; isActive: boolean }[];
|
||||
/** "<Name> <Jahr>" des Honigs, den die automatische Wahl gerade nimmt; null = keiner reicht. */
|
||||
currentHoneyLabel: string | null;
|
||||
action: (formData: FormData) => void | Promise<void>;
|
||||
t: SponsorshipAdminStrings;
|
||||
}
|
||||
@@ -74,7 +73,7 @@ const euroOrEmpty = (cents: number): string => (cents > 0 ? centsToEuroInput(cen
|
||||
* stehen mit "(nur Einmalig)" bzw. "(nur Abo)" da und werden beim Speichern
|
||||
* geleert (offer-schema.ts) — so bleibt das Formular ohne Client-Logik.
|
||||
*/
|
||||
export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFormProps) {
|
||||
export function OfferForm({ defaults, isNew, currentHoneyLabel, action, t }: OfferFormProps) {
|
||||
const tf = t.offerForm;
|
||||
return (
|
||||
<AdminFormPanel action={action} actions={<Button type="submit">{isNew ? tf.create : tf.save}</Button>}>
|
||||
@@ -123,15 +122,15 @@ export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFor
|
||||
<AdminFieldset legend={tf.sectionHoney}>
|
||||
<AdminSwitch name="isPhysical" label={tf.isPhysical} defaultChecked={defaults.isPhysical} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminSelect name="honeyProductId" label={tf.honeyProduct} defaultValue={defaults.honeyProductId ?? ""}>
|
||||
<option value="">{tf.honeyProductNone}</option>
|
||||
{honeyOptions.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
{product.isActive ? "" : tf.inactiveSuffix}
|
||||
</option>
|
||||
))}
|
||||
</AdminSelect>
|
||||
{/* Keine Auswahl mehr: Pakete und Abos bekommen den Honig automatisch
|
||||
(lib/sponsorship/honey-selection.ts). Nur Anzeige, wird nicht gespeichert. */}
|
||||
<AdminField
|
||||
name="honeyDisplay"
|
||||
label={tf.honey}
|
||||
defaultValue={tf.honeyAuto(currentHoneyLabel)}
|
||||
readOnly
|
||||
hint={tf.honeyAutoHint}
|
||||
/>
|
||||
<AdminField name="honeyJars" label={tf.honeyJars} type="number" min={0} defaultValue={String(defaults.honeyJars)} />
|
||||
</div>
|
||||
</AdminFieldset>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AdminActionButton, AdminBadge } from "@/components/admin-list";
|
||||
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { liftSepaBlockAction, updateSepaHoldDaysAction } from "@/app/admin/actions";
|
||||
import { MAX_SEPA_HOLD_DAYS } from "@/lib/sponsorship/settings";
|
||||
import type { HoneyCoverageRow } from "../_lib/coverage";
|
||||
import type { HoneyCoverage } from "../_lib/coverage";
|
||||
|
||||
type Strings = SponsorshipAdminStrings["subscriptions"];
|
||||
|
||||
@@ -16,13 +16,13 @@ export function SepaBlockButton({ customerId, confirmMessage, label }: { custome
|
||||
);
|
||||
}
|
||||
|
||||
/** Honigdeckung je Produkt; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
|
||||
export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: Strings }) {
|
||||
/** Deckung der Abos durch den automatisch gewaehlten (nicht-cremigen) Honig; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
|
||||
export function CoveragePanel({ coverage, t }: { coverage: HoneyCoverage; t: Strings }) {
|
||||
return (
|
||||
<section id="deckung" className="border border-ink/10 bg-background p-6">
|
||||
<h2 className="mb-2 font-serif text-xl text-heading">{t.coverageHeading}</h2>
|
||||
<p className="mb-4 text-xs text-ink/65">{t.coverageIntro}</p>
|
||||
{rows.length === 0 ? (
|
||||
{coverage.rows.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.noCoverage}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -30,38 +30,40 @@ export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: String
|
||||
<thead className="border-b border-ink/10 text-left text-xs text-ink/65">
|
||||
<tr>
|
||||
<th className="p-3 font-normal">{t.colProduct}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colYear}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colStock}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colReserved}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colFree}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colPerDelivery}</th>
|
||||
<th className="p-3 font-normal">{t.colCovers}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{rows.map((row) => (
|
||||
{coverage.rows.map((row) => (
|
||||
<tr key={row.productId}>
|
||||
<td className="p-3">{row.productName}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.harvestYear}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.stock}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.reserved}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.free}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.jarsPerDelivery}</td>
|
||||
<td className="p-3">
|
||||
{row.deliveriesCovered === null ? (
|
||||
"—"
|
||||
) : row.deliveriesCovered < 1 ? (
|
||||
<AdminBadge tone="danger" pulse>
|
||||
{t.coverageWarning}
|
||||
</AdminBadge>
|
||||
) : (
|
||||
<span className="tabular-nums">{t.covers(row.deliveriesCovered)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{coverage.deliveriesCovered !== null ? (
|
||||
<p className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink/75">
|
||||
<span className="tabular-nums">{t.perDelivery(coverage.jarsPerDelivery)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{t.coversLabel}:</span>
|
||||
{coverage.deliveriesCovered < 1 ? (
|
||||
<AdminBadge tone="danger" pulse>
|
||||
{t.coverageWarning}
|
||||
</AdminBadge>
|
||||
) : (
|
||||
<span className="tabular-nums text-heading">{t.covers(coverage.deliveriesCovered)}</span>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ const PRINT_GRACE_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
*/
|
||||
export async function getSponsorshipAttentionItems(locale: Locale, now: Date = new Date()): Promise<AttentionItem[]> {
|
||||
const t = getSponsorshipAdminStrings(locale).attention;
|
||||
// Honigdeckung zaehlt hoechstens 1: Abos haben keinen festen Honig mehr,
|
||||
// gedeckt wird aus allem nicht-cremigen Honig zusammen (coverage.ts).
|
||||
const [failed, overdueToPrint, delinquent, coverage] = await Promise.all([
|
||||
prisma.sponsorship.count({ where: { status: "FEHLER" } }),
|
||||
// mailSentAt ist der Moment, in dem die Patenschaft BEREIT wurde (die
|
||||
@@ -35,7 +37,7 @@ export async function getSponsorshipAttentionItems(locale: Locale, now: Date = n
|
||||
return [
|
||||
{ count: failed, href: "/admin/patenschaften?status=FEHLER", label: t.failed(failed) },
|
||||
{ count: overdueToPrint, href: "/admin/patenschaften?status=ZU_DRUCKEN", label: t.toPrint(overdueToPrint) },
|
||||
{ count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage(undercovered) },
|
||||
{ count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage },
|
||||
{ count: delinquent, href: "/admin/patenschaften/abos?status=IM_VERZUG", label: t.delinquent(delinquent) },
|
||||
].filter((item) => item.count > 0);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
import { freeStock, subscriptionReservationByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
import { loadHoneyStock } from "@/lib/sponsorship/honey-stock";
|
||||
import { rankHoneyCandidates, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||||
|
||||
/**
|
||||
* Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
|
||||
* Kontingent"). Reserviert wird in lib/sponsorship-subscription/reservation.ts
|
||||
* berechnet; hier wird nur zusammengestellt und angezeigt.
|
||||
* Kontingent"). Pakete und Abos bekommen automatisch nicht-cremigen Honig
|
||||
* (lib/sponsorship/honey-selection.ts) — gezeigt wird deshalb genau dieser
|
||||
* Honig, in der Rangfolge der automatischen Wahl. Reserviert wird in
|
||||
* lib/sponsorship-subscription/reservation.ts berechnet; hier wird nur
|
||||
* zusammengestellt und angezeigt.
|
||||
*/
|
||||
|
||||
/** Abos, deren naechste Lieferung noch Honig braucht (wie in reservation.ts). */
|
||||
@@ -16,13 +21,20 @@ export interface HoneyStock {
|
||||
free: number;
|
||||
}
|
||||
|
||||
export interface HoneyCoverageRow extends HoneyStock {
|
||||
export interface EligibleHoneyRow extends HoneyStock {
|
||||
productId: string;
|
||||
productName: string;
|
||||
isActive: boolean;
|
||||
weightGrams: number;
|
||||
harvestYear: number;
|
||||
consistency: string;
|
||||
}
|
||||
|
||||
export interface HoneyCoverage {
|
||||
/** Nicht-cremiger, aktiver Honig in der Rangfolge der automatischen Wahl. */
|
||||
rows: EligibleHoneyRow[];
|
||||
/** Glaeser, die eine vierteljaehrliche Lieferrunde aller laufenden Abos braucht. */
|
||||
jarsPerDelivery: number;
|
||||
/** Wie viele Lieferrunden der Bestand bedient; null = kein vierteljaehrlicher Bedarf. */
|
||||
/** Wie viele Lieferrunden der gesamte passende Bestand bedient; null = kein vierteljaehrlicher Bedarf. */
|
||||
deliveriesCovered: number | null;
|
||||
}
|
||||
|
||||
@@ -32,69 +44,58 @@ export function deliveriesCovered(stock: number, jarsPerDelivery: number): numbe
|
||||
return Math.floor(Math.max(0, stock) / jarsPerDelivery);
|
||||
}
|
||||
|
||||
/** Bestand, Abo-Reservierung und freier Bestand je Produkt. */
|
||||
export async function getHoneyStock(productIds: string[], now: Date = new Date()): Promise<Map<string, HoneyStock>> {
|
||||
const ids = [...new Set(productIds)];
|
||||
if (ids.length === 0) return new Map();
|
||||
const inventories = await prisma.inventory.findMany({
|
||||
where: { productId: { in: ids } },
|
||||
select: { productId: true, quantity: true },
|
||||
});
|
||||
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
|
||||
const reservedById = await reservedJarsByProduct(ids, now);
|
||||
return new Map(
|
||||
ids.map((id) => {
|
||||
const stock = stockById.get(id) ?? 0;
|
||||
const reserved = reservedById.get(id) ?? 0;
|
||||
return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const;
|
||||
})
|
||||
);
|
||||
/**
|
||||
* Passender Honig (aktiv, nicht cremig) mit Bestand, Abo-Reservierung und
|
||||
* freiem Bestand, in der Rangfolge der automatischen Wahl (bei gleicher Ernte
|
||||
* nach freiem Bestand). Eine Abfrage-Runde fuer alle Produkte.
|
||||
*/
|
||||
export async function getEligibleHoneyStock(now: Date = new Date()): Promise<EligibleHoneyRow[]> {
|
||||
const stock = await loadHoneyStock();
|
||||
const reserved = await subscriptionReservationByProduct(now, prisma, stock);
|
||||
const withFree = stock.map((candidate) => ({
|
||||
...candidate,
|
||||
freeStock: freeStock(candidate.inventory, reserved.get(candidate.id) ?? 0),
|
||||
}));
|
||||
return rankHoneyCandidates(withFree).map((candidate) => ({
|
||||
productId: candidate.id,
|
||||
productName: candidate.name,
|
||||
weightGrams: candidate.weightGrams,
|
||||
harvestYear: candidate.harvestYear,
|
||||
consistency: candidate.consistency,
|
||||
stock: candidate.inventory,
|
||||
reserved: reserved.get(candidate.id) ?? 0,
|
||||
free: candidate.freeStock,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */
|
||||
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverageRow[]> {
|
||||
const offers = await prisma.sponsorshipOffer.findMany({
|
||||
where: { kind: "ABO", honeyProductId: { not: null } },
|
||||
select: { honeyProduct: { select: { id: true, name: true, isActive: true } } },
|
||||
});
|
||||
const products = new Map(
|
||||
offers.flatMap((offer) => (offer.honeyProduct ? [[offer.honeyProduct.id, offer.honeyProduct] as const] : []))
|
||||
);
|
||||
if (products.size === 0) return [];
|
||||
/** Zeilen zurueck in Kandidaten der automatischen Wahl (freier Bestand). */
|
||||
export function toHoneyCandidates(rows: readonly EligibleHoneyRow[]): HoneyCandidate[] {
|
||||
return rows.map((row) => ({
|
||||
id: row.productId,
|
||||
name: row.productName,
|
||||
weightGrams: row.weightGrams,
|
||||
harvestYear: row.harvestYear,
|
||||
consistency: row.consistency,
|
||||
inventory: row.stock,
|
||||
freeStock: row.free,
|
||||
}));
|
||||
}
|
||||
|
||||
const [stock, subscriptions] = await Promise.all([
|
||||
getHoneyStock([...products.keys()], now),
|
||||
/** Deckung der Abos durch den passenden Honig insgesamt. */
|
||||
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverage> {
|
||||
const [rows, subscriptions] = await Promise.all([
|
||||
getEligibleHoneyStock(now),
|
||||
prisma.sponsorshipSubscription.findMany({
|
||||
where: {
|
||||
status: { in: [...DEMANDING_STATUSES] },
|
||||
offer: { cycle: "VIERTELJAEHRLICH", honeyProductId: { in: [...products.keys()] } },
|
||||
},
|
||||
select: { offer: { select: { honeyProductId: true, honeyJars: true } } },
|
||||
where: { status: { in: [...DEMANDING_STATUSES] }, offer: { cycle: "VIERTELJAEHRLICH", honeyJars: { gt: 0 } } },
|
||||
select: { offer: { select: { honeyJars: true } } },
|
||||
}),
|
||||
]);
|
||||
const demand = new Map<string, number>();
|
||||
for (const { offer } of subscriptions) {
|
||||
if (!offer.honeyProductId) continue;
|
||||
demand.set(offer.honeyProductId, (demand.get(offer.honeyProductId) ?? 0) + offer.honeyJars);
|
||||
}
|
||||
|
||||
return [...products.values()]
|
||||
.map((product) => {
|
||||
const amounts = stock.get(product.id) ?? { stock: 0, reserved: 0, free: 0 };
|
||||
const jarsPerDelivery = demand.get(product.id) ?? 0;
|
||||
return {
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
isActive: product.isActive,
|
||||
...amounts,
|
||||
jarsPerDelivery,
|
||||
deliveriesCovered: deliveriesCovered(amounts.stock, jarsPerDelivery),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.productName.localeCompare(b.productName, "de"));
|
||||
const jarsPerDelivery = subscriptions.reduce((sum, { offer }) => sum + offer.honeyJars, 0);
|
||||
const totalStock = rows.reduce((sum, row) => sum + row.stock, 0);
|
||||
return { rows, jarsPerDelivery, deliveriesCovered: deliveriesCovered(totalStock, jarsPerDelivery) };
|
||||
}
|
||||
|
||||
/** Fuer die Aufmerksamkeitsliste: Produkte, deren Bestand keine volle Lieferrunde mehr deckt. */
|
||||
export function countUndercovered(rows: HoneyCoverageRow[]): number {
|
||||
return rows.filter((row) => row.deliveriesCovered !== null && row.deliveriesCovered < 1).length;
|
||||
/** Fuer die Aufmerksamkeitsliste: 1, wenn der passende Honig keine volle Lieferrunde mehr deckt. */
|
||||
export function countUndercovered(coverage: Pick<HoneyCoverage, "deliveriesCovered">): number {
|
||||
return coverage.deliveriesCovered !== null && coverage.deliveriesCovered < 1 ? 1 : 0;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isOfferAvailable } from "@/lib/sponsorship/offers";
|
||||
import { getHoneyStock, type HoneyStock } from "./coverage";
|
||||
import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||
import { getEligibleHoneyStock, toHoneyCandidates, type EligibleHoneyRow, type HoneyStock } from "./coverage";
|
||||
|
||||
/** Abos, die einen Platz im Kontingent belegen (wie lib/sponsorship/offers.ts). */
|
||||
const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
@@ -8,35 +9,61 @@ const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
async function loadOffers(where: { id?: string }) {
|
||||
return prisma.sponsorshipOffer.findMany({
|
||||
where,
|
||||
include: {
|
||||
honeyProduct: { select: { id: true, name: true, isActive: true } },
|
||||
product: { select: { id: true, priceCents: true } },
|
||||
},
|
||||
include: { product: { select: { id: true, priceCents: true } } },
|
||||
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export type OfferRow = Awaited<ReturnType<typeof loadOffers>>[number];
|
||||
|
||||
/** Der Honig, den die automatische Wahl gerade fuer ein Angebot nimmt. */
|
||||
export interface CurrentHoney extends HoneyStock {
|
||||
productId: string;
|
||||
/** "<Name> <Jahr>" */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OfferOverviewRow {
|
||||
offer: OfferRow;
|
||||
/** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */
|
||||
isAvailable: boolean;
|
||||
activeSubscriptions: number;
|
||||
honeyStock: HoneyStock | null;
|
||||
/** null bei Angeboten ohne Glaeser oder wenn kein passender Honig reicht. */
|
||||
currentHoney: CurrentHoney | null;
|
||||
}
|
||||
|
||||
export interface OfferOverview {
|
||||
rows: OfferOverviewRow[];
|
||||
/** Passender Honig (nicht cremig) in der Rangfolge der automatischen Wahl. */
|
||||
eligibleHoney: EligibleHoneyRow[];
|
||||
}
|
||||
|
||||
/** Die automatische Wahl fuer `jars` Glaeser als Anzeige-Datensatz — oder null. */
|
||||
export function currentHoneyFor(eligibleHoney: readonly EligibleHoneyRow[], jars: number): CurrentHoney | null {
|
||||
if (jars <= 0) return null;
|
||||
const picked = pickHoneyProduct(toHoneyCandidates(eligibleHoney), jars);
|
||||
const row = picked ? eligibleHoney.find((candidate) => candidate.productId === picked.id) : undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
productId: row.productId,
|
||||
label: honeyLabel({ name: row.productName, harvestYear: row.harvestYear }),
|
||||
stock: row.stock,
|
||||
reserved: row.reserved,
|
||||
free: row.free,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Angebote samt Live-Verfuegbarkeit und Honigbestand. Fuer ALLE Angebote,
|
||||
* auch inaktive — getActiveOffers() aus lib/sponsorship/offers.ts kennt nur
|
||||
* aktive, der Admin will aber sehen, ob ein Angebot nach dem Aktivieren
|
||||
* ueberhaupt bestellbar waere. Die Entscheidung selbst bleibt isOfferAvailable.
|
||||
* Angebote samt Live-Verfuegbarkeit und automatisch gewaehltem Honig. Fuer
|
||||
* ALLE Angebote, auch inaktive — getActiveOffers() aus
|
||||
* lib/sponsorship/offers.ts kennt nur aktive, der Admin will aber sehen, ob
|
||||
* ein Angebot nach dem Aktivieren ueberhaupt bestellbar waere. Die
|
||||
* Entscheidung selbst bleibt isOfferAvailable.
|
||||
*/
|
||||
export async function getOfferOverview(offerId?: string): Promise<OfferOverviewRow[]> {
|
||||
export async function getOfferOverview(offerId?: string): Promise<OfferOverview> {
|
||||
const offers = await loadOffers(offerId ? { id: offerId } : {});
|
||||
const honeyIds = offers.flatMap((offer) => (offer.honeyProductId ? [offer.honeyProductId] : []));
|
||||
const [stock, groups] = await Promise.all([
|
||||
getHoneyStock(honeyIds),
|
||||
const [eligibleHoney, groups] = await Promise.all([
|
||||
getEligibleHoneyStock(),
|
||||
prisma.sponsorshipSubscription.groupBy({
|
||||
by: ["offerId"],
|
||||
where: { offerId: { in: offers.map((offer) => offer.id) }, status: { in: [...QUOTA_STATUSES] } },
|
||||
@@ -45,35 +72,23 @@ export async function getOfferOverview(offerId?: string): Promise<OfferOverviewR
|
||||
]);
|
||||
const subscriptionCounts = new Map(groups.map((group) => [group.offerId, group._count._all]));
|
||||
|
||||
return offers.map((offer) => {
|
||||
const honeyStock = offer.honeyProductId ? (stock.get(offer.honeyProductId) ?? null) : null;
|
||||
const rows = offers.map((offer) => {
|
||||
const currentHoney = currentHoneyFor(eligibleHoney, offer.honeyJars);
|
||||
const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0;
|
||||
return {
|
||||
offer,
|
||||
activeSubscriptions,
|
||||
honeyStock,
|
||||
currentHoney,
|
||||
isAvailable: isOfferAvailable({
|
||||
isActive: offer.isActive,
|
||||
kind: offer.kind,
|
||||
hasCarrierProduct: offer.productId !== null,
|
||||
honeyJars: offer.honeyJars,
|
||||
honeyProductActive: offer.honeyProduct ? offer.honeyProduct.isActive : null,
|
||||
freeJars: honeyStock?.free ?? 0,
|
||||
hasEligibleHoney: currentHoney !== null,
|
||||
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||
activeSubscriptions,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Auswahl im Formular: aktive Honigprodukte, plus das aktuell verknuepfte, falls es inzwischen inaktiv ist. */
|
||||
export async function getHoneyProductOptions(currentId: string | null): Promise<{ id: string; name: string; isActive: boolean }[]> {
|
||||
return prisma.product.findMany({
|
||||
where: {
|
||||
category: "HONIG",
|
||||
OR: [{ isActive: true }, ...(currentId ? [{ id: currentId }] : [])],
|
||||
},
|
||||
select: { id: true, name: true, isActive: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return { rows, eligibleHoney };
|
||||
}
|
||||
@@ -16,7 +16,6 @@ const oneTime = {
|
||||
price: "39,00",
|
||||
honeySharePerJar: "7",
|
||||
isPhysical: "on",
|
||||
honeyProductId: "prod_1",
|
||||
honeyJars: "1",
|
||||
medalTier: "SILBER",
|
||||
};
|
||||
@@ -29,7 +28,6 @@ const subscription = {
|
||||
price: "12,50",
|
||||
honeySharePerMonth: "4",
|
||||
isPhysical: "on",
|
||||
honeyProductId: "prod_1",
|
||||
honeyJars: "2",
|
||||
cycle: "VIERTELJAEHRLICH",
|
||||
medalTier: "ABO",
|
||||
@@ -83,14 +81,20 @@ describe("parseOfferForm", () => {
|
||||
expect(result).toEqual({ ok: false, message: "Bei Gläsern im Paket bitte den Honiganteil je Glas angeben." });
|
||||
});
|
||||
|
||||
it("verlangt fuer physische Angebote Honigprodukt und Glas", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "", honeyJars: "0", honeySharePerJar: "" }));
|
||||
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas." });
|
||||
it("verlangt fuer physische Angebote mindestens ein Glas", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyJars: "0", honeySharePerJar: "" }));
|
||||
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht mindestens ein Glas Honig." });
|
||||
});
|
||||
|
||||
it("nimmt kein festes Honigprodukt mehr an — der Honig wird automatisch gewaehlt", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "prod_1" }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value).not.toHaveProperty("honeyProductId");
|
||||
});
|
||||
|
||||
it("erlaubt ein rein digitales Angebot ohne Honig", () => {
|
||||
const result = parseOfferForm(
|
||||
form({ ...oneTime, isPhysical: "", honeyProductId: "", honeyJars: "0", honeySharePerJar: "", price: "19" })
|
||||
form({ ...oneTime, isPhysical: "", honeyJars: "0", honeySharePerJar: "", price: "19" })
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(carrierPriceCents(result.value)).toBe(1900);
|
||||
|
||||
@@ -96,11 +96,6 @@ const offerFormSchema = z
|
||||
isActive: z.boolean(),
|
||||
isPhysical: z.boolean(),
|
||||
sortOrder: intField("Reihenfolge", 0, 9999),
|
||||
honeyProductId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(64)
|
||||
.transform((value) => (value === "" ? null : value)),
|
||||
honeyJars: intField("Gläser", 0, 24),
|
||||
cycle: z.union([z.enum(OFFER_CYCLES), z.literal("")]).transform((value) => (value === "" ? null : value)),
|
||||
maxActiveSubscriptions: z.union([z.literal(""), intField("Höchstzahl aktiver Abos", 1, 100_000)]).transform((value) =>
|
||||
@@ -111,10 +106,10 @@ const offerFormSchema = z
|
||||
.superRefine((offer, ctx) => {
|
||||
const issue = (message: string) => ctx.addIssue({ code: "custom", message });
|
||||
if (offer.priceCents <= 0) issue("Der Preis muss größer als 0 € sein.");
|
||||
if (offer.honeyJars > 0 && !offer.honeyProductId) issue("Gläser ohne Honigprodukt: bitte ein Honigprodukt wählen.");
|
||||
if (offer.isPhysical && (!offer.honeyProductId || offer.honeyJars < 1)) {
|
||||
issue("Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas.");
|
||||
}
|
||||
// Welcher Honig, waehlt das System automatisch (nie cremig, neueste
|
||||
// Ernte — lib/sponsorship/honey-selection.ts); hier geht es nur um die
|
||||
// Menge. Ein festes Honigprodukt nimmt das Formular nicht mehr an.
|
||||
if (offer.isPhysical && offer.honeyJars < 1) issue("Ein physisches Angebot braucht mindestens ein Glas Honig.");
|
||||
if (offer.kind === "EINMALIG") {
|
||||
// Der Rest nach Abzug des Honiganteils ist der Preis des Traegerprodukts
|
||||
// (Urkunde und Leistung zum Regelsatz) — er muss positiv bleiben.
|
||||
@@ -170,7 +165,6 @@ export function parseOfferForm(
|
||||
isActive: formData.get("isActive") === "on",
|
||||
isPhysical: formData.get("isPhysical") === "on",
|
||||
sortOrder: field(formData, "sortOrder") || "0",
|
||||
honeyProductId: field(formData, "honeyProductId"),
|
||||
honeyJars: field(formData, "honeyJars") || "0",
|
||||
cycle: field(formData, "cycle"),
|
||||
maxActiveSubscriptions: field(formData, "maxActiveSubscriptions").trim(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
import { nextDeliveryView } from "./subscription-view";
|
||||
import { countUndercovered, deliveriesCovered, type HoneyCoverageRow } from "./coverage";
|
||||
import { countUndercovered, deliveriesCovered } from "./coverage";
|
||||
|
||||
describe("nextDeliveryView", () => {
|
||||
it("vierteljaehrlich: zaehlt die fehlenden bezahlten Monate", () => {
|
||||
@@ -44,17 +44,9 @@ describe("Honigdeckung", () => {
|
||||
expect(deliveriesCovered(10, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("zaehlt nur Produkte unter einer Lieferrunde", () => {
|
||||
const row = (deliveries: number | null): HoneyCoverageRow => ({
|
||||
productId: "p",
|
||||
productName: "Honig",
|
||||
isActive: true,
|
||||
stock: 0,
|
||||
reserved: 0,
|
||||
free: 0,
|
||||
jarsPerDelivery: deliveries === null ? 0 : 2,
|
||||
deliveriesCovered: deliveries,
|
||||
});
|
||||
expect(countUndercovered([row(0), row(1), row(null), row(0)])).toBe(2);
|
||||
it("meldet hoechstens einen Eintrag: wenn der passende Honig zusammen keine Lieferrunde mehr deckt", () => {
|
||||
expect(countUndercovered({ deliveriesCovered: 0 })).toBe(1);
|
||||
expect(countUndercovered({ deliveriesCovered: 1 })).toBe(0);
|
||||
expect(countUndercovered({ deliveriesCovered: null })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export default async function AdminSponsorshipSubscriptionsPage(props: PageProps
|
||||
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<CoveragePanel rows={coverage} t={ts} />
|
||||
<CoveragePanel coverage={coverage} t={ts} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:col-span-2">
|
||||
<SepaSettingsPanel holdDays={holdDays} t={ts} />
|
||||
|
||||
@@ -7,16 +7,17 @@ import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { updateSponsorshipOfferAction } from "@/app/admin/actions";
|
||||
import { OfferForm } from "../../_components/offer-form";
|
||||
import { getHoneyProductOptions, getOfferOverview } from "../../_lib/offer-overview";
|
||||
import { getOfferOverview } from "../../_lib/offer-overview";
|
||||
|
||||
export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/admin/patenschaften/angebote/[id]">) {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const tf = t.offerForm;
|
||||
const { id } = await props.params;
|
||||
const [row] = await getOfferOverview(id);
|
||||
const {
|
||||
rows: [row],
|
||||
} = await getOfferOverview(id);
|
||||
if (!row) notFound();
|
||||
const { offer, isAvailable, honeyStock, activeSubscriptions } = row;
|
||||
const honeyOptions = await getHoneyProductOptions(offer.honeyProductId);
|
||||
const { offer, isAvailable, currentHoney, activeSubscriptions } = row;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
@@ -30,8 +31,12 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a
|
||||
<AdminBadge tone={isAvailable ? "positive" : "pending"}>{isAvailable ? t.offers.available : t.offers.unavailable}</AdminBadge>
|
||||
<span className="text-sm text-ink/75">{isAvailable ? tf.availableNow : tf.unavailableNow}</span>
|
||||
</div>
|
||||
{honeyStock ? (
|
||||
<p className="mt-3 text-sm tabular-nums text-ink/75">{tf.stockLine(honeyStock.stock, honeyStock.reserved, honeyStock.free)}</p>
|
||||
{currentHoney ? (
|
||||
<p className="mt-3 text-sm tabular-nums text-ink/75">
|
||||
{tf.stockLine(currentHoney.label, currentHoney.stock, currentHoney.reserved, currentHoney.free)}
|
||||
</p>
|
||||
) : offer.honeyJars > 0 ? (
|
||||
<p className="mt-3 text-sm text-ink/75">{tf.noEligibleHoney}</p>
|
||||
) : null}
|
||||
{offer.kind === "ABO" ? (
|
||||
<p className="mt-1 text-sm tabular-nums text-ink/75">{tf.activeSubscriptions(activeSubscriptions, offer.maxActiveSubscriptions)}</p>
|
||||
@@ -45,7 +50,7 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a
|
||||
|
||||
<OfferForm
|
||||
isNew={false}
|
||||
honeyOptions={honeyOptions}
|
||||
currentHoneyLabel={currentHoney?.label ?? null}
|
||||
action={updateSponsorshipOfferAction}
|
||||
t={t}
|
||||
defaults={{
|
||||
@@ -64,7 +69,6 @@ export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/a
|
||||
isActive: offer.isActive,
|
||||
isPhysical: offer.isPhysical,
|
||||
sortOrder: offer.sortOrder,
|
||||
honeyProductId: offer.honeyProductId,
|
||||
honeyJars: offer.honeyJars,
|
||||
cycle: offer.cycle,
|
||||
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||
|
||||
@@ -3,16 +3,19 @@ import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { createSponsorshipOfferAction } from "@/app/admin/actions";
|
||||
import { EMPTY_OFFER, OfferForm } from "../../_components/offer-form";
|
||||
import { getHoneyProductOptions } from "../../_lib/offer-overview";
|
||||
import { currentHoneyFor } from "../../_lib/offer-overview";
|
||||
import { getEligibleHoneyStock } from "../../_lib/coverage";
|
||||
|
||||
export default async function AdminSponsorshipOfferNewPage() {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const honeyOptions = await getHoneyProductOptions(null);
|
||||
// Vorschau der automatischen Wahl fuer ein Glas — die echte Wahl richtet
|
||||
// sich nach den Glaesern des Angebots und faellt bei jeder Bestellung neu.
|
||||
const currentHoney = currentHoneyFor(await getEligibleHoneyStock(), 1);
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/patenschaften/angebote" label={t.offerForm.backToOffers} />
|
||||
<h1 className="mb-8 text-2xl text-heading">{t.offerForm.newTitle}</h1>
|
||||
<OfferForm defaults={EMPTY_OFFER} isNew honeyOptions={honeyOptions} action={createSponsorshipOfferAction} t={t} />
|
||||
<OfferForm defaults={EMPTY_OFFER} isNew currentHoneyLabel={currentHoney?.label ?? null} action={createSponsorshipOfferAction} t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,23 +10,16 @@ import { formatPrice } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
|
||||
import { getOfferOverview, type OfferOverviewRow } from "../_lib/offer-overview";
|
||||
|
||||
/** Je verknuepftem Honigprodukt eine Zeile (mehrere Angebote koennen dasselbe Produkt nutzen). */
|
||||
function honeyStockRows(rows: OfferOverviewRow[]) {
|
||||
const byProduct = new Map<string, { name: string; isActive: boolean; stock: number; reserved: number; free: number }>();
|
||||
for (const { offer, honeyStock } of rows) {
|
||||
if (!offer.honeyProduct || !honeyStock || byProduct.has(offer.honeyProduct.id)) continue;
|
||||
byProduct.set(offer.honeyProduct.id, { name: offer.honeyProduct.name, isActive: offer.honeyProduct.isActive, ...honeyStock });
|
||||
}
|
||||
return [...byProduct.entries()].sort(([, a], [, b]) => a.name.localeCompare(b.name, "de"));
|
||||
}
|
||||
import { getOfferOverview } from "../_lib/offer-overview";
|
||||
|
||||
export default async function AdminSponsorshipOffersPage() {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const to = t.offers;
|
||||
const rows = await getOfferOverview();
|
||||
const stockRows = honeyStockRows(rows);
|
||||
// Honig waehlt das System automatisch (nie cremig, neueste Ernte); gezeigt
|
||||
// wird hier der passende Honig in der Rangfolge der Wahl.
|
||||
const { rows, eligibleHoney } = await getOfferOverview();
|
||||
// "Erste Wahl" = der Honig, den ein Paket mit einem Glas gerade bekaeme.
|
||||
const firstChoiceId = eligibleHoney.find((row) => row.free > 0)?.productId;
|
||||
const newOffer = { href: "/admin/patenschaften/angebote/neu", label: to.newOffer };
|
||||
|
||||
return (
|
||||
@@ -50,7 +43,7 @@ export default async function AdminSponsorshipOffersPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{rows.map(({ offer, isAvailable }) => (
|
||||
{rows.map(({ offer, isAvailable, currentHoney }) => (
|
||||
<tr key={offer.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4">
|
||||
<span className="text-heading">{offer.name}</span>
|
||||
@@ -66,7 +59,11 @@ export default async function AdminSponsorshipOffersPage() {
|
||||
{offer.kind === "ABO" ? <span className="text-ink/65"> {to.perMonth}</span> : null}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{offer.honeyProduct && offer.honeyJars > 0 ? to.jars(offer.honeyJars, offer.honeyProduct.name) : <span className="text-ink/60">{to.noHoney}</span>}
|
||||
{offer.honeyJars > 0 ? (
|
||||
to.honeyAuto(offer.honeyJars, currentHoney?.label ?? null)
|
||||
) : (
|
||||
<span className="text-ink/60">{to.noHoney}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">{t.medals[offer.medalTier]}</td>
|
||||
<td className="p-4">
|
||||
@@ -91,27 +88,36 @@ export default async function AdminSponsorshipOffersPage() {
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-3 text-lg text-heading">{to.stockHeading}</h2>
|
||||
{stockRows.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{to.noLinkedHoney}</p>
|
||||
<h2 className="mb-1 text-lg text-heading">{to.stockHeading}</h2>
|
||||
<p className="mb-3 max-w-prose text-xs text-ink/65">{to.stockIntro}</p>
|
||||
{eligibleHoney.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{to.noEligibleHoney}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto border border-ink/10 bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-ink/65">
|
||||
<tr>
|
||||
<th className="p-4">{to.colProduct}</th>
|
||||
<th className="p-4 text-right">{to.colYear}</th>
|
||||
<th className="p-4">{to.colConsistency}</th>
|
||||
<th className="p-4 text-right">{to.colStock}</th>
|
||||
<th className="p-4 text-right">{to.colReserved}</th>
|
||||
<th className="p-4 text-right">{to.colFree}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{stockRows.map(([productId, row]) => (
|
||||
<tr key={productId}>
|
||||
{eligibleHoney.map((row) => (
|
||||
<tr key={row.productId}>
|
||||
<td className="p-4">
|
||||
{row.name}
|
||||
{row.isActive ? null : <span className="ml-2 text-xs text-ink/60">({to.inactive})</span>}
|
||||
{row.productName}
|
||||
{row.productId === firstChoiceId ? (
|
||||
<span className="ml-2">
|
||||
<AdminBadge tone="positive">{to.firstChoice}</AdminBadge>
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-4 text-right tabular-nums">{row.harvestYear}</td>
|
||||
<td className="p-4">{row.consistency}</td>
|
||||
<td className="p-4 text-right tabular-nums">{row.stock}</td>
|
||||
<td className="p-4 text-right tabular-nums">{row.reserved}</td>
|
||||
<td className="p-4 text-right tabular-nums text-heading">{row.free}</td>
|
||||
|
||||
Reference in new issue
Block a user