From 91f6fef6eacc5159dedc3710a8e7bd8ff9c72598 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Jul 2026 11:30:46 +0000 Subject: [PATCH] =?UTF-8?q?Support=20partial=20returns=20=E2=80=94=20per-i?= =?UTF-8?q?tem=20quantity,=20item-only=20Gutschrift=20(no=20shipping=20ref?= =?UTF-8?q?und,=20no=20discount=20reproration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers can now select which items and how many units to return instead of only the whole order. The Gutschrift reflects only the returned quantities, excludes shipping (already delivered), and leaves the original discount untouched — confirmed policy, not an engineering default. Stornorechnung (pre-shipping cancellation) is unaffected and stays a full reversal including shipping. --- app/api/account/orders/[orderNumber]/route.ts | 59 ++++++-- .../components/OrderActionButton.tsx | 140 ++++++++++++++---- app/konto/bestellungen/[orderNumber]/page.tsx | 11 +- app/lib/correctionInvoicePdf.tsx | 52 +++++-- app/lib/customerAuth.ts | 47 ++++-- 5 files changed, 247 insertions(+), 62 deletions(-) diff --git a/app/api/account/orders/[orderNumber]/route.ts b/app/api/account/orders/[orderNumber]/route.ts index 433c397..ccdb0c7 100644 --- a/app/api/account/orders/[orderNumber]/route.ts +++ b/app/api/account/orders/[orderNumber]/route.ts @@ -4,13 +4,15 @@ import { getCustomerOrderDetail, requestOrderStatusChange, customerOrderAction, + type CustomerOrderItem, } from "../../../../lib/customerAuth"; // The real security boundary is Orders.ts's beforeChange hook in Payload -// (only `status` can change, only via an allowed transition) — the check -// against customerOrderAction() here is just for a friendlier error -// message than a bare 403 when the button's already stale (e.g. two tabs -// open, order shipped in the meantime). +// (only `status`/`returnReason`/items' `returnQuantity` can change, only +// via an allowed transition) — the checks here are just for a friendlier +// error message than a bare 403 when the request is malformed or stale +// (e.g. two tabs open, order shipped in the meantime, a quantity that no +// longer fits). export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) { const session = await getSessionCustomer(); if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 }); @@ -21,10 +23,6 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or if (action !== "cancel" && action !== "request-return") { return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 }); } - const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : ""; - if (action === "request-return" && !returnReason) { - return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 }); - } const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber)); if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 }); @@ -32,6 +30,47 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 }); } - const result = await requestOrderStatusChange(session.token, order.id, action, returnReason || undefined); - return NextResponse.json(result, { status: result.ok ? 200 : 400 }); + if (action === "cancel") { + const result = await requestOrderStatusChange(session.token, order.id, "cancel"); + return NextResponse.json(result, { status: result.ok ? 200 : 400 }); + } + + // request-return: partial returns supported — the client sends + // { product, returnQuantity } per line it wants to return (0 or + // omitted for lines being kept). Reconstruct the order's FULL items + // array here (see requestOrderStatusChange's own comment on why a + // sparse patch doesn't work), validating each requested quantity + // against what was actually ordered. + const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : ""; + if (!returnReason) { + return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 }); + } + const requestedQuantities = new Map(); + if (Array.isArray(body?.returnItems)) { + for (const line of body.returnItems) { + const product = Number(line?.product); + const returnQuantity = Number(line?.returnQuantity); + if (Number.isFinite(product) && Number.isFinite(returnQuantity) && returnQuantity > 0) { + requestedQuantities.set(product, returnQuantity); + } + } + } + if (requestedQuantities.size === 0) { + return NextResponse.json({ ok: false, reason: "Bitte mindestens einen Artikel mit Menge auswählen." }, { status: 400 }); + } + + const items: CustomerOrderItem[] = order.items.map((item) => { + const requested = requestedQuantities.get(item.product) ?? 0; + if (requested > item.quantity) { + throw Object.assign(new Error("returnQuantity exceeds ordered quantity"), { status: 400 }); + } + return { ...item, returnQuantity: requested }; + }); + + try { + const result = await requestOrderStatusChange(session.token, order.id, "request-return", { returnReason, items }); + return NextResponse.json(result, { status: result.ok ? 200 : 400 }); + } catch { + return NextResponse.json({ ok: false, reason: "Eine der Mengen übersteigt die bestellte Menge." }, { status: 400 }); + } } diff --git a/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx index 8e47f7e..b530b39 100644 --- a/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx +++ b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx @@ -4,39 +4,35 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; const LABEL = { cancel: "Bestellung stornieren", "request-return": "Rücksendung anfragen" } as const; -const CONFIRM = { - cancel: "Bestellung wirklich stornieren?", -} as const; -export function OrderActionButton({ orderNumber, action }: { orderNumber: string; action: "cancel" | "request-return" }) { +export type ReturnableItem = { product: number; productName: string; quantity: number }; + +export function OrderActionButton({ + orderNumber, + action, + items, +}: { + orderNumber: string; + action: "cancel" | "request-return"; + items: ReturnableItem[]; +}) { const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [returnReason, setReturnReason] = useState(""); + // Keyed by product id, string so the input can hold an empty/partial + // value while typing — parsed to a number only on submit. + const [quantities, setQuantities] = useState>({}); - async function handleClick() { - let returnReason: string | undefined; - if (action === "cancel") { - if (!window.confirm(CONFIRM.cancel)) return; - } else { - // A short reason helps quality/assortment decisions later (see the - // Payload README's Orders.ts section) — prompt() rather than a - // custom form, same "plain browser dialog" pattern already used for - // cancel's confirm() above. - const input = window.prompt("Kurz gesagt, warum möchtest du die Bestellung zurücksenden?"); - if (input === null) return; - returnReason = input.trim(); - if (!returnReason) { - setError("Bitte kurz einen Grund angeben."); - return; - } - } + async function submit(body: Record) { setLoading(true); setError(null); try { const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, returnReason }), + body: JSON.stringify(body), }); const data = await res.json(); if (!data.ok) { @@ -51,16 +47,104 @@ export function OrderActionButton({ orderNumber, action }: { orderNumber: string } } - return ( -
+ async function handleCancel() { + if (!window.confirm("Bestellung wirklich stornieren?")) return; + await submit({ action: "cancel" }); + } + + async function handleReturnSubmit() { + const trimmedReason = returnReason.trim(); + if (!trimmedReason) { + setError("Bitte kurz einen Grund angeben."); + return; + } + const returnItems = Object.entries(quantities) + .map(([product, value]) => ({ product: Number(product), returnQuantity: Number(value) || 0 })) + .filter((line) => line.returnQuantity > 0); + if (returnItems.length === 0) { + setError("Bitte mindestens einen Artikel mit Menge auswählen."); + return; + } + await submit({ action: "request-return", returnReason: trimmedReason, returnItems }); + } + + if (action === "cancel") { + return ( +
+ + {error &&

{error}

} +
+ ); + } + + // request-return: a short inline form, not window.prompt() — needs a + // per-item quantity (partial returns are supported, see the Payload + // README's "How a Stornorechnung/Gutschrift relates..." section), which + // a single-line browser prompt can't reasonably capture. + if (!formOpen) { + return ( + ); + } + + return ( +
+

Welche Artikel möchtest du zurücksenden?

+
+ {items.map((item) => ( +
+ {item.productName} + + / {item.quantity} +
+ ))} +
+