feat(11-03): CPV-Filter + Wert-min/max-UI in Builder, DTO, FilterPanel

TenderQueryDto gains a validated cpv[] field (single-or-repeated query
param, normalized via @Transform); buildTenderWhere adds a cpvDivisions
hasSome branch (FILTER-03, Pitfall 2 — never an exact match against raw
cpvCodes). FilterPanel gets a CPV-Division autocomplete (search-by-label,
multi-select chips, repeated ?cpv= params) plus the previously
backend-only value filter's UI: valueMin/valueMax number inputs and an
"ohne Wertangabe einschließen" toggle (default on, matches the builder's
includeNullValue default from Plan 11-01) so the 91.6% NULL-value rows
stay visible by default. Verified against the live DB: cpv=45 matches all
three raw formats ("45", "45000000", "45000000-7") via the backfilled
cpvDivisions column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:14:51 +02:00
parent f9591dc491
commit d60bfd1966
4 changed files with 261 additions and 5 deletions
@@ -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>
);
}