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:
@@ -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) => (
|
||||
|
||||
+57
-1
@@ -498,15 +498,61 @@ function excludeFailedPaymentAttemptsQuery(): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
// "Offen" in the account UI's PaymentStatusBadge covers two distinct
|
||||
// backend values (an unconfirmed Stripe payment vs. an Überweisung order
|
||||
// awaiting manual reconciliation) — the filter chip mirrors that same
|
||||
// grouping rather than exposing the internal distinction as two options.
|
||||
const OPEN_PAYMENT_STATUSES = ["pending", "not_applicable"] as const;
|
||||
|
||||
export type CustomerOrderFilters = {
|
||||
status?: string;
|
||||
paymentStatus?: string;
|
||||
/** Calendar year as a string, e.g. "2026" — matches createdAt within
|
||||
* [Jan 1, Jan 1 of next year). */
|
||||
year?: string;
|
||||
};
|
||||
|
||||
function customerOrderFilterQuery(filters: CustomerOrderFilters | undefined, whereIndex: number): Record<string, string> {
|
||||
if (!filters) return {};
|
||||
const params: Record<string, string> = {};
|
||||
let i = whereIndex;
|
||||
if (filters.status) {
|
||||
params[`where[and][${i}][status][equals]`] = filters.status;
|
||||
i += 1;
|
||||
}
|
||||
if (filters.paymentStatus) {
|
||||
if (filters.paymentStatus === "open") {
|
||||
OPEN_PAYMENT_STATUSES.forEach((value, j) => {
|
||||
params[`where[and][${i}][or][${j}][paymentStatus][equals]`] = value;
|
||||
});
|
||||
} else {
|
||||
params[`where[and][${i}][paymentStatus][equals]`] = filters.paymentStatus;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if (filters.year && /^\d{4}$/.test(filters.year)) {
|
||||
const year = Number(filters.year);
|
||||
params[`where[and][${i}][createdAt][greater_than_equal]`] = new Date(Date.UTC(year, 0, 1)).toISOString();
|
||||
params[`where[and][${i}][createdAt][less_than]`] = new Date(Date.UTC(year + 1, 0, 1)).toISOString();
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// `excludeFailedPaymentAttempts` defaults to true (list views) — the one
|
||||
// exception is /api/account/export/route.ts's GDPR data export, which
|
||||
// passes false: a legal completeness export must include every order
|
||||
// row that exists about this customer, not just the ones normally shown
|
||||
// in "Meine Bestellungen".
|
||||
export async function getCustomerOrders(token: string, customerId: number, excludeFailedPaymentAttempts = true): Promise<CustomerOrder[]> {
|
||||
export async function getCustomerOrders(
|
||||
token: string,
|
||||
customerId: number,
|
||||
excludeFailedPaymentAttempts = true,
|
||||
filters?: CustomerOrderFilters,
|
||||
): Promise<CustomerOrder[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[and][0][customer][equals]": String(customerId),
|
||||
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
|
||||
...customerOrderFilterQuery(filters, excludeFailedPaymentAttempts ? 2 : 1),
|
||||
sort: "-createdAt",
|
||||
depth: "0",
|
||||
limit: "50",
|
||||
@@ -537,6 +583,16 @@ export async function getCustomerOrders(token: string, customerId: number, exclu
|
||||
}));
|
||||
}
|
||||
|
||||
// All years that have at least one (non-filtered-out) order for this
|
||||
// customer — powers the year filter's option list without hardcoding a
|
||||
// range. Cheap: reuses the same excludeFailedPaymentAttempts query, no
|
||||
// separate collection/aggregation endpoint needed for this order volume.
|
||||
export async function getCustomerOrderYears(token: string, customerId: number): Promise<string[]> {
|
||||
const orders = await getCustomerOrders(token, customerId, true);
|
||||
const years = new Set(orders.map((o) => new Date(o.createdAt).getUTCFullYear().toString()));
|
||||
return Array.from(years).sort((a, b) => Number(b) - Number(a));
|
||||
}
|
||||
|
||||
export type CustomerOrderDetail = CustomerOrder & {
|
||||
id: number;
|
||||
// 'manual' (Überweisung) vs 'stripe' (Kreditkarte/PayPal) — see
|
||||
|
||||
Reference in New Issue
Block a user