Add status/paymentStatus/year filters to the order overview

Server-side, URL-search-param-driven filtering (?status=&paymentStatus=&year=)
so the page stays a Server Component — each filter chip is a plain Link
to the same route with one param changed, shareable/bookmarkable/back-
button-safe for free. getCustomerOrders() gained an optional filters
param translated into additional Payload where[] clauses; a new
getCustomerOrderYears() derives the year filter's option list from the
customer's actual orders instead of a hardcoded range. paymentStatus's
"Offen" filter option mirrors PaymentStatusBadge's own grouping
(pending + not_applicable as one option, not two).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 22:03:30 +00:00
parent e7d5dd8b1e
commit 2f8c184dc6
2 changed files with 168 additions and 5 deletions
+111 -4
View File
@@ -4,7 +4,12 @@ import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { formatPrice, formatDate } from "../../lib/format";
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
import {
getSessionCustomer,
getCustomerOrders,
getCustomerOrderYears,
ORDER_STATUS_LABEL,
} from "../../lib/customerAuth";
import { OrderStatusBadge } from "../components/OrderStatusBadge";
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
import { LogoutButton } from "../components/LogoutButton";
@@ -19,11 +24,72 @@ export const metadata: Metadata = {
},
};
export default async function KontoBestellungenPage() {
// Mirrors PaymentStatusBadge's own grouping ("Offen" covers both
// not_applicable and pending) — see getCustomerOrders's OPEN_PAYMENT_STATUSES.
const PAYMENT_STATUS_FILTER_LABEL: Record<string, string> = {
open: "Offen",
paid: "Bezahlt",
failed: "Fehlgeschlagen",
refunded: "Erstattet",
partially_refunded: "Teilweise erstattet",
};
function FilterChip({
href,
active,
children,
}: {
href: string;
active: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
active
? "bg-brand border-brand text-text-primary"
: "bg-bg-base border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
{children}
</Link>
);
}
// Every filter is expressed as plain URL search params (?status=…&paymentStatus=…&year=…)
// rather than client-side state — this page stays a Server Component, each
// chip is just a <Link> to the same route with one param changed/removed,
// and the filtered result is shareable/bookmarkable/back-button-safe for
// free. buildFilterHref() only ever changes ONE param at a time, keeping
// the other active filters intact (a status filter + a year filter can
// both be active together).
function buildFilterHref(current: Record<string, string | undefined>, key: string, value: string | undefined): string {
const params = new URLSearchParams();
const next = { ...current, [key]: value };
for (const [k, v] of Object.entries(next)) {
if (v) params.set(k, v);
}
const qs = params.toString();
return qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen";
}
export default async function KontoBestellungenPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; paymentStatus?: string; year?: string }>;
}) {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const orders = await getCustomerOrders(session.token, session.customer.id);
const { status, paymentStatus, year } = await searchParams;
const activeFilters = { status, paymentStatus, year };
const [orders, availableYears] = await Promise.all([
getCustomerOrders(session.token, session.customer.id, true, { status, paymentStatus, year }),
getCustomerOrderYears(session.token, session.customer.id),
]);
const hasAnyFilter = Boolean(status || paymentStatus || year);
return (
<>
@@ -36,8 +102,49 @@ export default async function KontoBestellungenPage() {
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
</p>
{orders.length === 0 ? (
{availableYears.length > 0 && (
<div className="flex flex-wrap gap-2 w-full">
<FilterChip href={buildFilterHref(activeFilters, "status", undefined)} active={!status}>
Alle Status
</FilterChip>
{Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => (
<FilterChip key={value} href={buildFilterHref(activeFilters, "status", value)} active={status === value}>
{label}
</FilterChip>
))}
<div className="hidden sm:block w-px h-6 self-center bg-border" />
<FilterChip href={buildFilterHref(activeFilters, "paymentStatus", undefined)} active={!paymentStatus}>
Alle Zahlungsstatus
</FilterChip>
{Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => (
<FilterChip key={value} href={buildFilterHref(activeFilters, "paymentStatus", value)} active={paymentStatus === value}>
{label}
</FilterChip>
))}
<div className="hidden sm:block w-px h-6 self-center bg-border" />
<FilterChip href={buildFilterHref(activeFilters, "year", undefined)} active={!year}>
Alle Jahre
</FilterChip>
{availableYears.map((y) => (
<FilterChip key={y} href={buildFilterHref(activeFilters, "year", y)} active={year === y}>
{y}
</FilterChip>
))}
{hasAnyFilter && (
<Link
href="/konto/bestellungen"
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Filter zurücksetzen
</Link>
)}
</div>
)}
{availableYears.length === 0 ? (
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
) : orders.length === 0 ? (
<p className="text-body text-text-muted">Keine Bestellungen gefunden, die zu den gewählten Filtern passen.</p>
) : (
<div className="flex flex-col gap-4 w-full">
{orders.map((order) => (