From 2f8c184dc61c0387e0986077b32a2722110cbd71 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 30 Jul 2026 22:03:30 +0000 Subject: [PATCH] Add status/paymentStatus/year filters to the order overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/konto/bestellungen/page.tsx | 115 ++++++++++++++++++++++++++++++-- app/lib/customerAuth.ts | 58 +++++++++++++++- 2 files changed, 168 insertions(+), 5 deletions(-) diff --git a/app/konto/bestellungen/page.tsx b/app/konto/bestellungen/page.tsx index 969aa3b..e6d00ad 100644 --- a/app/konto/bestellungen/page.tsx +++ b/app/konto/bestellungen/page.tsx @@ -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 = { + 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 ( + + {children} + + ); +} + +// 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 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, 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})

- {orders.length === 0 ? ( + {availableYears.length > 0 && ( +
+ + Alle Status + + {Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => ( + + {label} + + ))} +
+ + Alle Zahlungsstatus + + {Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => ( + + {label} + + ))} +
+ + Alle Jahre + + {availableYears.map((y) => ( + + {y} + + ))} + {hasAnyFilter && ( + + Filter zurücksetzen + + )} +
+ )} + + {availableYears.length === 0 ? (

Du hast noch keine Bestellung aufgegeben.

+ ) : orders.length === 0 ? ( +

Keine Bestellungen gefunden, die zu den gewählten Filtern passen.

) : (
{orders.map((order) => ( diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index fa8295d..2a5ef34 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -498,15 +498,61 @@ function excludeFailedPaymentAttemptsQuery(): Record { }; } +// "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 { + if (!filters) return {}; + const params: Record = {}; + 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 { +export async function getCustomerOrders( + token: string, + customerId: number, + excludeFailedPaymentAttempts = true, + filters?: CustomerOrderFilters, +): Promise { 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 { + 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