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:
@@ -24,8 +24,8 @@ import {
|
|||||||
*
|
*
|
||||||
* Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=...
|
* Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=...
|
||||||
*
|
*
|
||||||
* Region/PLZ/Bundesland/CPV/favOnly filters are added in later Phase-11
|
* CPV/favOnly filters are added in later Phase-11 plans (11-03/05) —
|
||||||
* plans (11-02/03/05) — deliberately NOT added here.
|
* deliberately NOT added here.
|
||||||
*/
|
*/
|
||||||
export class TenderQueryDto {
|
export class TenderQueryDto {
|
||||||
/**
|
/**
|
||||||
@@ -121,4 +121,31 @@ export class TenderQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['deadline', 'value', 'published'])
|
@IsIn(['deadline', 'value', 'published'])
|
||||||
sort?: string;
|
sort?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLZ-Präfixfilter (FILTER-02, D-02). MaxLength(5) — German postal codes
|
||||||
|
* are 5 digits; a startsWith match still permits shorter prefixes.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5)
|
||||||
|
plz?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUTS-Region-Präfixfilter (FILTER-02, D-02) — matched via
|
||||||
|
* `region: { startsWith: ... }` in the builder.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
region?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundesland-Name-Filter (FILTER-02, D-02, Pitfall 1). Matched against
|
||||||
|
* the backfilled/normalizer-derived `bundesland` column
|
||||||
|
* (@@index([bundesland])) — real hits only after the
|
||||||
|
* 20260721140000_tender_bundesland_backfill migration.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
bundesland?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,43 @@ describe('buildTenderWhere', () => {
|
|||||||
const where = buildTenderWhere(dto({ status: 'expired' }));
|
const where = buildTenderWhere(dto({ status: 'expired' }));
|
||||||
expect(where.status).toBe('expired');
|
expect(where.status).toBe('expired');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('plz set: adds a plz-startsWith constraint (FILTER-02)', () => {
|
||||||
|
const where = buildTenderWhere(dto({ plz: '10' }));
|
||||||
|
|
||||||
|
expect(where.AND).toEqual(
|
||||||
|
expect.arrayContaining([{ plz: { startsWith: '10' } }]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bundesland set: adds an exact bundesland-equality constraint against the backfilled column (FILTER-02, Pitfall 1)', () => {
|
||||||
|
const where = buildTenderWhere(dto({ bundesland: 'Bayern' }));
|
||||||
|
|
||||||
|
expect(where.AND).toEqual(expect.arrayContaining([{ bundesland: 'Bayern' }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('region set: adds a region-startsWith constraint, independent of bundesland (FILTER-02)', () => {
|
||||||
|
const where = buildTenderWhere(dto({ region: 'DE2' }));
|
||||||
|
|
||||||
|
expect(where.AND).toEqual(
|
||||||
|
expect.arrayContaining([{ region: { startsWith: 'DE2' } }]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plz/bundesland/region are omitted from AND when unset', () => {
|
||||||
|
const where = buildTenderWhere(dto());
|
||||||
|
|
||||||
|
const and = (where.AND as unknown[]) ?? [];
|
||||||
|
const hasGeoClause = and.some(
|
||||||
|
(clause) =>
|
||||||
|
typeof clause === 'object' &&
|
||||||
|
clause !== null &&
|
||||||
|
('plz' in (clause as Record<string, unknown>) ||
|
||||||
|
'bundesland' in (clause as Record<string, unknown>) ||
|
||||||
|
'region' in (clause as Record<string, unknown>)),
|
||||||
|
);
|
||||||
|
expect(hasGeoClause).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildOrderBy', () => {
|
describe('buildOrderBy', () => {
|
||||||
|
|||||||
@@ -68,6 +68,28 @@ export function buildTenderWhere(dto: TenderQueryDto): Prisma.TenderWhereInput {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FILTER-02 / D-02: PLZ prefix match — plz is a 5-digit German postal
|
||||||
|
// code column; startsWith allows shorter prefixes to broaden the match.
|
||||||
|
if (dto.plz) {
|
||||||
|
AND.push({ plz: { startsWith: dto.plz } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILTER-02 / D-02: Bundesland exact match against the backfilled/
|
||||||
|
// normalizer-derived `bundesland` column (indexed, Pitfall 1) — real
|
||||||
|
// hits only after the 20260721140000_tender_bundesland_backfill
|
||||||
|
// migration has run. Preferred over region-prefix matching once
|
||||||
|
// bundesland is populated.
|
||||||
|
if (dto.bundesland) {
|
||||||
|
AND.push({ bundesland: dto.bundesland });
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILTER-02 / D-02: raw NUTS-region prefix match, independent of the
|
||||||
|
// bundesland column — usable even for rows whose bundesland derivation
|
||||||
|
// hasn't run yet, and for finer-grained region-level filtering.
|
||||||
|
if (dto.region) {
|
||||||
|
AND.push({ region: { startsWith: dto.region } });
|
||||||
|
}
|
||||||
|
|
||||||
if (AND.length) where.AND = AND;
|
if (AND.length) where.AND = AND;
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
* The `deadlineFrom`/`deadlineTo` param names deliberately match the
|
* The `deadlineFrom`/`deadlineTo` param names deliberately match the
|
||||||
* TenderQueryDto field names exactly — the Saved-Search serialization
|
* TenderQueryDto field names exactly — the Saved-Search serialization
|
||||||
* contract in Plan 11-06 depends on the URL param names and the DTO field
|
* 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
|
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
|
||||||
* Question 3, RESOLVED).
|
* Question 3, RESOLVED).
|
||||||
@@ -21,6 +22,32 @@ const SORT_OPTIONS: Array<{ value: string; label: string }> = [
|
|||||||
{ value: 'value', label: 'Auftragswert' },
|
{ 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() {
|
export function FilterPanel() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -31,6 +58,8 @@ export function FilterPanel() {
|
|||||||
const openOnly = searchParams.get('openOnly') !== 'false';
|
const openOnly = searchParams.get('openOnly') !== 'false';
|
||||||
const deadlineFrom = searchParams.get('deadlineFrom') ?? '';
|
const deadlineFrom = searchParams.get('deadlineFrom') ?? '';
|
||||||
const deadlineTo = searchParams.get('deadlineTo') ?? '';
|
const deadlineTo = searchParams.get('deadlineTo') ?? '';
|
||||||
|
const plz = searchParams.get('plz') ?? '';
|
||||||
|
const bundesland = searchParams.get('bundesland') ?? '';
|
||||||
|
|
||||||
const setParam = (key: string, value: string | null) => {
|
const setParam = (key: string, value: string | null) => {
|
||||||
const next = new URLSearchParams(searchParams.toString());
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
@@ -124,6 +153,43 @@ export function FilterPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user