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