Support partial returns — per-item quantity, item-only Gutschrift (no shipping refund, no discount reproration)
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.
This commit is contained in:
@@ -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<string | null>(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<Record<number, string>>({});
|
||||
|
||||
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<string, unknown>) {
|
||||
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 (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
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 (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "…" : LABEL.cancel}
|
||||
</button>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
onClick={() => setFormOpen(true)}
|
||||
className="px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors"
|
||||
>
|
||||
{loading ? "…" : LABEL[action]}
|
||||
{LABEL["request-return"]}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Welche Artikel möchtest du zurücksenden?</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.product} className="flex items-center gap-4">
|
||||
<span className="flex-1 text-body-sm text-text-primary">{item.productName}</span>
|
||||
<label className="flex items-center gap-2 text-label text-text-muted">
|
||||
Menge
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.quantity}
|
||||
value={quantities[item.product] ?? ""}
|
||||
onChange={(e) => setQuantities((prev) => ({ ...prev, [item.product]: e.target.value }))}
|
||||
placeholder="0"
|
||||
className="w-16 px-2 py-1 border border-border rounded-sm text-body-sm text-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<span className="text-label text-text-muted">/ {item.quantity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-label text-text-muted">Grund der Rücksendung</span>
|
||||
<textarea
|
||||
value={returnReason}
|
||||
onChange={(e) => setReturnReason(e.target.value)}
|
||||
rows={2}
|
||||
className="px-3 py-2 border border-border rounded-sm text-body-sm text-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReturnSubmit}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm bg-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "…" : "Rücksendung anfragen"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setFormOpen(false)} className="text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -73,6 +73,9 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
{item.quantity} × {item.productName}
|
||||
</p>
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
|
||||
</div>
|
||||
@@ -137,7 +140,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && <OrderActionButton orderNumber={order.orderNumber} action={action} />}
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
Reference in New Issue
Block a user