diff --git a/apps/api/src/tenders/dto/tender-query.dto.ts b/apps/api/src/tenders/dto/tender-query.dto.ts index aa9417d..5349f49 100644 --- a/apps/api/src/tenders/dto/tender-query.dto.ts +++ b/apps/api/src/tenders/dto/tender-query.dto.ts @@ -1,5 +1,6 @@ -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { + IsArray, IsBoolean, IsDate, IsIn, @@ -24,8 +25,8 @@ import { * * Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=... * - * CPV/favOnly filters are added in later Phase-11 plans (11-03/05) — - * deliberately NOT added here. + * favOnly filter is added in a later Phase-11 plan (11-05) — deliberately + * NOT added here. */ export class TenderQueryDto { /** @@ -148,4 +149,21 @@ export class TenderQueryDto { @IsOptional() @IsString() bundesland?: string; + + /** + * Ausgewählte CPV-Divisionen (FILTER-03, D-03) — matched against the + * normalizer/backfill-derived `cpvDivisions` column via `hasSome` in the + * builder (Pitfall 2: never an exact match against raw cpvCodes). + * Normalizes a single-value query string (`?cpv=45`) to a one-element + * array — Express's default query parser only produces an array when the + * key is repeated (`?cpv=45&cpv=71`), so a lone selection would otherwise + * fail @IsArray validation. + */ + @IsOptional() + @Transform(({ value }) => + value === undefined ? value : Array.isArray(value) ? value : [value], + ) + @IsArray() + @IsString({ each: true }) + cpv?: string[]; } diff --git a/apps/api/src/tenders/tender-query.builder.spec.ts b/apps/api/src/tenders/tender-query.builder.spec.ts index 62c466a..8c44651 100644 --- a/apps/api/src/tenders/tender-query.builder.spec.ts +++ b/apps/api/src/tenders/tender-query.builder.spec.ts @@ -172,6 +172,38 @@ describe('buildTenderWhere', () => { ); expect(hasGeoClause).toBe(false); }); + + it('cpv set: adds a cpvDivisions hasSome constraint (FILTER-03, Pitfall 2)', () => { + const where = buildTenderWhere(dto({ cpv: ['45'] })); + + expect(where.AND).toEqual( + expect.arrayContaining([{ cpvDivisions: { hasSome: ['45'] } }]), + ); + }); + + it('cpv with multiple divisions: hasSome carries all selected divisions', () => { + const where = buildTenderWhere(dto({ cpv: ['45', '71'] })); + + expect(where.AND).toEqual( + expect.arrayContaining([{ cpvDivisions: { hasSome: ['45', '71'] } }]), + ); + }); + + it('cpv omitted or empty is never added to AND', () => { + const whereUnset = buildTenderWhere(dto()); + const whereEmpty = buildTenderWhere(dto({ cpv: [] })); + + for (const where of [whereUnset, whereEmpty]) { + const and = (where.AND as unknown[]) ?? []; + const hasCpvClause = and.some( + (clause) => + typeof clause === 'object' && + clause !== null && + 'cpvDivisions' in (clause as Record), + ); + expect(hasCpvClause).toBe(false); + } + }); }); describe('buildOrderBy', () => { diff --git a/apps/api/src/tenders/tender-query.builder.ts b/apps/api/src/tenders/tender-query.builder.ts index 12bdc4e..49a37f0 100644 --- a/apps/api/src/tenders/tender-query.builder.ts +++ b/apps/api/src/tenders/tender-query.builder.ts @@ -90,6 +90,14 @@ export function buildTenderWhere(dto: TenderQueryDto): Prisma.TenderWhereInput { AND.push({ region: { startsWith: dto.region } }); } + // FILTER-03 / D-03 (Pitfall 2): CPV-Division-Filter — matched via + // `hasSome` against the normalizer/backfill-derived `cpvDivisions` + // column (typesafe, GIN-indexable), never an exact-equality match + // against the inconsistently-formatted raw `cpvCodes` array. + if (dto.cpv?.length) { + AND.push({ cpvDivisions: { hasSome: dto.cpv } }); + } + if (AND.length) where.AND = AND; return where; } diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx index 8043702..68507d2 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; /** @@ -9,8 +10,9 @@ 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). `plz` and - * `bundesland` (Plan 11-02) follow the same convention. + * names being identical strings (11-01-PLAN.md action note). `plz`, + * `bundesland` (Plan 11-02), `cpv`, `valueMin`/`valueMax`/`includeNullValue` + * (Plan 11-03) follow the same convention. * * Hardcoded German strings — i18n rollout is Phase 14 (Research Open * Question 3, RESOLVED). @@ -48,9 +50,69 @@ const BUNDESLAND_OPTIONS: string[] = [ 'Thüringen', ]; +/** + * Mirrors apps/api/src/tenders/cpv/cpv-catalog.ts's CPV_DIVISIONS exactly + * (web and api are separate packages — same "kleine Konstante spiegeln" + * precedent as BUNDESLAND_OPTIONS above, Plan 11-02 Task 3 decision). Only + * the 2-digit CPV Division level ships here (D-03, Open Question 2 + * RESOLVED) — deliberately not the full EU CPV vocabulary. + */ +const CPV_DIVISION_OPTIONS: Array<{ code: string; label: string }> = [ + { code: '03', label: 'Landwirtschaftliche, fischereiwirtschaftliche, forstwirtschaftliche und verwandte Erzeugnisse' }, + { code: '09', label: 'Mineralölerzeugnisse, Brennstoffe, Elektrizität und andere Energiequellen' }, + { code: '14', label: 'Bergbauerzeugnisse, Grundstoffe und verwandte Erzeugnisse' }, + { code: '15', label: 'Nahrungsmittel, Getränke, Tabak und verwandte Erzeugnisse' }, + { code: '16', label: 'Land- und forstwirtschaftliche Maschinen' }, + { code: '18', label: 'Bekleidung, Schuhe, Reiseartikel und Zubehör' }, + { code: '19', label: 'Leder- und Textilwaren, Kunststoff- und Gummierzeugnisse' }, + { code: '22', label: 'Druckerzeugnisse und verwandte Erzeugnisse' }, + { code: '24', label: 'Chemische Erzeugnisse' }, + { code: '30', label: 'Büromaschinen, Datenverarbeitungsgeräte, -ausrüstung und -material, ausgenommen Möbel und Softwarepakete' }, + { code: '31', label: 'Elektrische Maschinen, Geräte, Ausrüstungen und Verbrauchsartikel; Beleuchtung' }, + { code: '32', label: 'Rundfunk-, Fernseh-, Kommunikations-, Telekommunikations- und verwandte Geräte' }, + { code: '33', label: 'Medizinische Apparate, pharmazeutische Erzeugnisse und Körperpflegeprodukte' }, + { code: '34', label: 'Transportausrüstung und Transportzubehör' }, + { code: '35', label: 'Sicherheits-, Feuerlösch-, Polizei- und Verteidigungsausrüstung' }, + { code: '37', label: 'Musikinstrumente, Sportartikel, Spiele, Spielzeug, Kunsthandwerk, Kunstmaterial und Zubehör' }, + { code: '38', label: 'Laborgeräte, optische und Präzisionsgeräte (ausgenommen Gläser)' }, + { code: '39', label: 'Möbel (einschließlich Büromöbel), Einrichtungsgegenstände, Haushaltsgeräte (ausgenommen Beleuchtung) und Reinigungsmittel' }, + { code: '41', label: 'Gesammeltes und gereinigtes Wasser' }, + { code: '42', label: 'Industriemaschinen' }, + { code: '43', label: 'Maschinen für Bergbau, Steinbruch, Bauwesen' }, + { code: '44', label: 'Baustoffe und zugehörige Artikel' }, + { code: '45', label: 'Bauarbeiten' }, + { code: '48', label: 'Softwarepaket und Informationssysteme' }, + { code: '50', label: 'Reparatur- und Wartungsdienste' }, + { code: '51', label: 'Installationsdienste (ausgenommen Software)' }, + { code: '55', label: 'Beherbergungs-, Gaststätten- und Handelsdienstleistungen' }, + { code: '60', label: 'Transportdienstleistungen (ohne Abfalltransport)' }, + { code: '63', label: 'Neben- und Hilfstätigkeiten im Verkehr; Reisebüros' }, + { code: '64', label: 'Post- und Telekommunikationsdienste' }, + { code: '65', label: 'Versorgungsbetriebe' }, + { code: '66', label: 'Finanz- und Versicherungsdienstleistungen' }, + { code: '70', label: 'Dienstleistungen im Zusammenhang mit Immobilien' }, + { code: '71', label: 'Architektur-, Bau-, Ingenieur- und Prüfdienste' }, + { code: '72', label: 'IT-Dienste: Beratung, Software-Entwicklung, Internet und Hilfestellung' }, + { code: '73', label: 'Forschungs- und Entwicklungsdienste sowie zugehörige Beratung' }, + { code: '75', label: 'Öffentliche Verwaltung, Verteidigung und Sozialversicherung' }, + { code: '76', label: 'Dienstleistungen im Zusammenhang mit der Öl- und Gasindustrie' }, + { code: '77', label: 'Landwirtschaftliche, forstwirtschaftliche, gartenbauliche Dienste, Aquakultur und Imkerei' }, + { code: '79', label: 'Unternehmensbezogene Dienstleistungen: Recht, Marketing, Consulting, Personal, Druck und Sicherheit' }, + { code: '80', label: 'Dienstleistungen des Bildungswesens und der Ausbildung' }, + { code: '85', label: 'Dienste im Gesundheits- und Sozialwesen' }, + { code: '90', label: 'Abwasser- und Abfallbeseitigung, Reinigung und Umweltschutzdienste' }, + { code: '92', label: 'Dienstleistungen der Freizeitgestaltung, der Kultur und des Sports' }, + { code: '98', label: 'Sonstige Gemeinschafts-, Sozial- und Individualdienstleistungen' }, +]; + +function labelForCpvDivision(code: string): string { + return CPV_DIVISION_OPTIONS.find((d) => d.code === code)?.label ?? code; +} + export function FilterPanel() { const router = useRouter(); const searchParams = useSearchParams(); + const [cpvSearchText, setCpvSearchText] = useState(''); const q = searchParams.get('q') ?? ''; const sort = searchParams.get('sort') ?? 'published'; @@ -60,6 +122,12 @@ export function FilterPanel() { const deadlineTo = searchParams.get('deadlineTo') ?? ''; const plz = searchParams.get('plz') ?? ''; const bundesland = searchParams.get('bundesland') ?? ''; + const selectedCpv = searchParams.getAll('cpv'); + const valueMin = searchParams.get('valueMin') ?? ''; + const valueMax = searchParams.get('valueMax') ?? ''; + // includeNullValue defaults to true (D-05 Pflichtkriterium) — only an + // explicit "false" excludes rows without an estimatedValue. + const includeNullValue = searchParams.get('includeNullValue') !== 'false'; const setParam = (key: string, value: string | null) => { const next = new URLSearchParams(searchParams.toString()); @@ -74,6 +142,33 @@ export function FilterPanel() { router.replace(`?${next.toString()}`); }; + /** + * Multi-value `cpv` param — Express's default query parser only produces + * an array when the key is repeated (?cpv=45&cpv=71), so selections are + * written as repeated `cpv` entries rather than a single comma-joined + * value (matches the DTO's @Transform normalization on the API side). + */ + const toggleCpvDivision = (code: string) => { + const next = new URLSearchParams(searchParams.toString()); + next.delete('cpv'); + const nextSelected = selectedCpv.includes(code) + ? selectedCpv.filter((c) => c !== code) + : [...selectedCpv, code]; + for (const c of nextSelected) next.append('cpv', c); + next.delete('page'); + router.replace(`?${next.toString()}`); + }; + + const cpvMatches = useMemo(() => { + const text = cpvSearchText.trim().toLowerCase(); + if (!text) return []; + return CPV_DIVISION_OPTIONS.filter( + (d) => + !selectedCpv.includes(d.code) && + (d.code.includes(text) || d.label.toLowerCase().includes(text)), + ).slice(0, 8); + }, [cpvSearchText, selectedCpv]); + const inputCls = 'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground'; const labelCls = 'mb-1 block text-sm text-foreground'; @@ -190,6 +285,109 @@ export function FilterPanel() { + +
+
+ + setCpvSearchText(e.target.value)} + className={inputCls} + autoComplete="off" + /> + {cpvMatches.length > 0 && ( +
    + {cpvMatches.map((d) => ( +
  • + +
  • + ))} +
+ )} + {selectedCpv.length > 0 && ( +
+ {selectedCpv.map((code) => ( + + {code} – {labelForCpvDivision(code)} + + + ))} +
+ )} +
+ +
+ + setParam('valueMin', e.target.value)} + className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground" + /> +
+
+ + setParam('valueMax', e.target.value)} + className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground" + /> +
+
+ + setParam('includeNullValue', e.target.checked ? null : 'false') + } + className="h-4 w-4" + /> + +
+
); }