@@ -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() {
< / select >
< / div >
< / div >
< div className = "flex flex-wrap items-start gap-4" >
< div className = "relative min-w-[280px] flex-1" >
< label htmlFor = "tr-filter-cpv" className = { labelCls } >
Branche ( CPV )
< / label >
< input
id = "tr-filter-cpv"
type = "text"
placeholder = "z. B. Bau, IT, Gesundheit…"
value = { cpvSearchText }
onChange = { ( e ) = > setCpvSearchText ( e . target . value ) }
className = { inputCls }
autoComplete = "off"
/ >
{ cpvMatches . length > 0 && (
< ul className = "absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded border border-border bg-card shadow-md" >
{ cpvMatches . map ( ( d ) = > (
< li key = { d . code } >
< button
type = "button"
onClick = { ( ) = > {
toggleCpvDivision ( d . code ) ;
setCpvSearchText ( '' ) ;
} }
className = "block w-full px-3 py-2 text-left text-sm text-foreground hover:bg-muted"
>
{ d . code } – { d . label }
< / button >
< / li >
) ) }
< / ul >
) }
{ selectedCpv . length > 0 && (
< div className = "mt-2 flex flex-wrap gap-2" >
{ selectedCpv . map ( ( code ) = > (
< span
key = { code }
className = "flex items-center gap-1 rounded-full border border-border bg-muted px-3 py-1 text-xs text-foreground"
>
{ code } – { labelForCpvDivision ( code ) }
< button
type = "button"
aria-label = { ` Division ${ code } entfernen ` }
onClick = { ( ) = > toggleCpvDivision ( code ) }
className = "text-muted-foreground hover:text-foreground"
>
×
< / button >
< / span >
) ) }
< / div >
) }
< / div >
< div >
< label htmlFor = "tr-filter-value-min" className = { labelCls } >
Auftragswert von ( € )
< / label >
< input
id = "tr-filter-value-min"
type = "number"
inputMode = "decimal"
min = { 0 }
placeholder = "min."
value = { valueMin }
onChange = { ( e ) = > setParam ( 'valueMin' , e . target . value ) }
className = "h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground"
/ >
< / div >
< div >
< label htmlFor = "tr-filter-value-max" className = { labelCls } >
Auftragswert bis ( € )
< / label >
< input
id = "tr-filter-value-max"
type = "number"
inputMode = "decimal"
min = { 0 }
placeholder = "max."
value = { valueMax }
onChange = { ( e ) = > setParam ( 'valueMax' , e . target . value ) }
className = "h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground"
/ >
< / div >
< div className = "flex items-center gap-2 pb-2" >
< input
id = "tr-filter-include-null-value"
type = "checkbox"
checked = { includeNullValue }
onChange = { ( e ) = >
setParam ( 'includeNullValue' , e . target . checked ? null : 'false' )
}
className = "h-4 w-4"
/ >
< label
htmlFor = "tr-filter-include-null-value"
className = "text-sm text-foreground"
>
Ohne Wertangabe einschließen
< / label >
< / div >
< / div >
< / div >
) ;
}