feat(11-02): wire Region/PLZ/Bundesland filter into query builder + FilterPanel

TenderQueryDto gains validated plz (@MaxLength(5)), region, and
bundesland fields. buildTenderWhere adds three conditional AND-branches:
plz startsWith, bundesland exact-match against the now-backfilled indexed
column (Pitfall 1), and region startsWith (usable independent of the
bundesland column). FilterPanel gets a PLZ input and a 16-Land Bundesland
dropdown (mirrors NUTS1_BUNDESLAND — web/api are separate packages) that
write plz/bundesland into the URL searchParams; ResultsList already
forwards the full URLSearchParams to listTenders(), so no additional
fetch wiring was needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:03:21 +02:00
parent e4db602597
commit 69e25298c9
4 changed files with 155 additions and 3 deletions
@@ -9,7 +9,8 @@ import { useRouter, useSearchParams } from 'next/navigation';
* The `deadlineFrom`/`deadlineTo` param names deliberately match the
* TenderQueryDto field names exactly — the Saved-Search serialization
* contract in Plan 11-06 depends on the URL param names and the DTO field
* names being identical strings (11-01-PLAN.md action note).
* names being identical strings (11-01-PLAN.md action note). `plz` and
* `bundesland` (Plan 11-02) follow the same convention.
*
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
* Question 3, RESOLVED).
@@ -21,6 +22,32 @@ const SORT_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'value', label: 'Auftragswert' },
];
/**
* Mirrors apps/api/src/tenders/geo/nuts-bundesland.ts's NUTS1_BUNDESLAND
* values (web and api are separate packages — no shared module for this
* small, stable 16-entry list; RESEARCH's "kleine Konstante spiegeln"
* option, FILTER-02 Plan 11-02 Task 3). Keep in sync if the Bundesland set
* ever changes (it will not — this is the fixed EU NUTS-1/DE schema).
*/
const BUNDESLAND_OPTIONS: string[] = [
'Baden-Württemberg',
'Bayern',
'Berlin',
'Brandenburg',
'Bremen',
'Hamburg',
'Hessen',
'Mecklenburg-Vorpommern',
'Niedersachsen',
'Nordrhein-Westfalen',
'Rheinland-Pfalz',
'Saarland',
'Sachsen',
'Sachsen-Anhalt',
'Schleswig-Holstein',
'Thüringen',
];
export function FilterPanel() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -31,6 +58,8 @@ export function FilterPanel() {
const openOnly = searchParams.get('openOnly') !== 'false';
const deadlineFrom = searchParams.get('deadlineFrom') ?? '';
const deadlineTo = searchParams.get('deadlineTo') ?? '';
const plz = searchParams.get('plz') ?? '';
const bundesland = searchParams.get('bundesland') ?? '';
const setParam = (key: string, value: string | null) => {
const next = new URLSearchParams(searchParams.toString());
@@ -124,6 +153,43 @@ export function FilterPanel() {
/>
</div>
</div>
<div className="flex flex-wrap items-end gap-4">
<div>
<label htmlFor="tr-filter-plz" className={labelCls}>
PLZ
</label>
<input
id="tr-filter-plz"
type="text"
inputMode="numeric"
maxLength={5}
placeholder="z. B. 10"
value={plz}
onChange={(e) => setParam('plz', e.target.value)}
className="h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground"
/>
</div>
<div className="min-w-[220px]">
<label htmlFor="tr-filter-bundesland" className={labelCls}>
Bundesland
</label>
<select
id="tr-filter-bundesland"
value={bundesland}
onChange={(e) => setParam('bundesland', e.target.value || null)}
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
>
<option value="">Alle Bundesländer</option>
{BUNDESLAND_OPTIONS.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
</div>
</div>
);
}