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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user