diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 4ecfe31..5e358e9 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -28,7 +28,7 @@ - [x] **FILTER-01**: Nutzer kann per Volltext-Stichwort (Titel/Beschreibung) suchen. - [x] **FILTER-02**: Nutzer kann nach Region / PLZ / Bundesland filtern. -- [ ] **FILTER-03**: Nutzer kann nach CPV-Code / Branche filtern (hierarchische Auswahl mit Autocomplete). +- [x] **FILTER-03**: Nutzer kann nach CPV-Code / Branche filtern (hierarchische Auswahl mit Autocomplete). - [x] **FILTER-04**: Nutzer kann nach Abgabefrist filtern, inkl. Option „nur noch offene". - [x] **FILTER-05**: Nutzer kann nach geschätztem Auftragswert (min/max) filtern; Ausschreibungen ohne Wertangabe werden korrekt behandelt. - [ ] **FILTER-06**: Nutzer kann Suchprofile (Kombination aus Filterkriterien) speichern, bearbeiten und löschen — pro Nutzer, mandantenbewusst. @@ -83,7 +83,7 @@ | SCHEMA-02 | Phase 10 | Complete | | FILTER-01 | Phase 11 | Complete | | FILTER-02 | Phase 11 | Complete | -| FILTER-03 | Phase 11 | Pending | +| FILTER-03 | Phase 11 | Complete | | FILTER-04 | Phase 11 | Complete | | FILTER-05 | Phase 11 | Complete | | FILTER-06 | Phase 11 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 742a4c8..e49ddc6 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -386,11 +386,11 @@ Plans: 4. User can mark a tender as read/unread and as favourite, and filter the results list to only favourites -- both states are per-user, not shared across the tenant 5. The UI clearly indicates data coverage (Oberschwelle vs. Unterschwelle) so an empty or thin result set isn't mistaken for a bug -**Plans**: 2/6 plans executed +**Plans**: 3/6 plans executed - [x] 11-01-PLAN.md — Trefferliste + Freitext + Sortierung + Coverage-Banner (FILTER-01/04/05, UI-01/05) - [x] 11-02-PLAN.md — Bundesland-Ableitung (NUTS + Backfill) + Region/PLZ/Bundesland-Filter (FILTER-02) -- [ ] 11-03-PLAN.md — CPV-Filter (Katalog + Divisionen) + Wert-Filter-UI (FILTER-03/05) +- [x] 11-03-PLAN.md — CPV-Filter (Katalog + Divisionen) + Wert-Filter-UI (FILTER-03/05) - [ ] 11-04-PLAN.md — Detailansicht via ?tender= + Quell-Link (UI-02) - [ ] 11-05-PLAN.md — Triage: gelesen/ungelesen + Favorit + Merklisten-Filter (UI-03/04) - [ ] 11-06-PLAN.md — Suchprofile speichern/bearbeiten/löschen (FILTER-06) @@ -462,7 +462,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 | 8. Dashboard Widgets Vollimplementierung | 4/4 | Complete | 2026-07-01 | | 9. Cert Manager Module | 6/6 | Complete | 2026-07-02 | | 10. Ausschreibungs-Radar Foundation & DÖE Ingestion | 6/6 | Complete | 2026-07-21 | -| 11. Filter Engine, Results UI & Saved Searches | 2/6 | In Progress| | +| 11. Filter Engine, Results UI & Saved Searches | 3/6 | In Progress| | | 12. Tender Notifications | 0/TBD | Not started | - | | 13. Scraping Adapters & Cross-Source Deduplication | 0/TBD | Not started | - | | 14. RSS, Email-Alert Ingestion & Module Rollout | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index a38f889..6bf3a4b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,15 +5,15 @@ milestone_name: Ausschreibungs-Radar current_phase: 11 current_phase_name: filter-engine-results-ui-saved-searches status: executing -stopped_at: Completed 11-02-PLAN.md -last_updated: "2026-07-21T14:05:18.391Z" +stopped_at: Completed 11-03-PLAN.md +last_updated: "2026-07-21T14:16:54.557Z" last_activity: 2026-07-21 last_activity_desc: Phase 11 execution started progress: total_phases: 11 completed_phases: 9 total_plans: 52 - completed_plans: 47 + completed_plans: 48 --- # Project State @@ -28,11 +28,11 @@ See: .planning/PROJECT.md (updated 2026-07-17) ## Current Position Phase: 11 (filter-engine-results-ui-saved-searches) — EXECUTING -Plan: 3 of 6 +Plan: 4 of 6 Status: Ready to execute Last activity: 2026-07-21 — Phase 11 execution started -Progress: [█████████░] 90% +Progress: [█████████░] 92% ## Performance Metrics @@ -82,6 +82,7 @@ Progress: [█████████░] 90% |------|----------|-------|-------| | Phase 11 P01 | 8min | 3 tasks | 11 files | | Phase 11 P02 | 4min | 3 tasks | 12 files | +| Phase 11 P03 | 9min | 4 tasks | 12 files | ## Accumulated Context @@ -170,6 +171,8 @@ Recent decisions affecting current work: - [Phase ?]: deadlineFrom/deadlineTo URL-Param-Namen sind 1:1 identisch zu TenderQueryDto-Feldnamen fuer den Saved-Search-Vertrag aus Plan 11-06 - [Phase ?]: bundesland-Filter matcht exakt gegen die befüllte, indexierte Spalte (statt region-startsWith); region bleibt als eigenständiger Präfixfilter erhalten. - [Phase ?]: BUNDESLAND_OPTIONS im Web als kleine Konstante gespiegelt (kein Shared-Package) — web nutzt @tessera/shared nicht, 16-Werte-Katalog rechtfertigt keine neue Cross-Package-Abhängigkeit. +- [Phase ?]: CPV-Divisions-Kurzkatalog (2-stellig, ~45 Einträge) statt EU-Vollkatalog; hasSome-Match gegen precomputed cpvDivisions-Spalte statt Raw-SQL-Präfix-Match +- [Phase ?]: cpv-URL-Param als wiederholter Key (?cpv=45&cpv=71) statt Komma-Join; DTO normalisiert Einzelwert per @Transform zu Array ### Pending Todos @@ -207,7 +210,7 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-07-21T14:05:18.378Z -Stopped at: Completed 11-02-PLAN.md +Last session: 2026-07-21T14:16:54.544Z +Stopped at: Completed 11-03-PLAN.md Resume file: None Last activity: 2026-07-14 - Built LDAP per-user exclude/denylist filter (9d1323f), migration applied on live DB, verified via Playwright: sync deactivated 4 excluded service accounts (administrator/krbtgt/guest/dns-ldap), 2 real LDAP users stay active, 0 wrongly created diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-SUMMARY.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-SUMMARY.md new file mode 100644 index 0000000..c47fb3b --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-SUMMARY.md @@ -0,0 +1,165 @@ +--- +phase: 11-filter-engine-results-ui-saved-searches +plan: 03 +subsystem: api, ui, database +tags: [nestjs, prisma, class-validator, cpv, nextjs-app-router, vitest] + +requires: + - phase: 11-filter-engine-results-ui-saved-searches + provides: "tender-query.builder.ts (buildTenderWhere/buildOrderBy), erweiterter TenderQueryDto, FilterPanel.tsx (Plan 11-01/11-02), backend-only NULL-graceful value filter (Plan 11-01)" +provides: + - "cpv/cpv-catalog.ts: normalizeCpv()/divisionOf()/cpvMatchesDivisions() + CPV_DIVISIONS (45-entry statischer Divisions-Katalog, DE-Labels)" + - "Tender.cpvDivisions String[] (@@index Gin) — normalizer-derived für neue Ingests + Backfill-Migration für Bestand" + - "buildTenderWhere: cpvDivisions-hasSome-Branch (FILTER-03)" + - "TenderQueryDto.cpv[] (Transform normalisiert Einzelwert/Array)" + - "FilterPanel.tsx: CPV-Autocomplete (Label-Suche, Mehrfachauswahl, Chips) + Wert-min/max-Eingaben + 'ohne Wertangabe einschließen'-Toggle (Default an)" +affects: [11-04-detail-view, 11-05-triage, 11-06-saved-searches] + +tech-stack: + added: [] + patterns: + - "Zweiseitige Normalisierung (normalizeCpv/divisionOf) statt exaktem Match — löst das Format-Inkonsistenz-Problem an der Wurzel, nicht durch Sonderfälle im Query-Builder" + - "Precomputed hasSome-Spalte (cpvDivisions) statt Raw-SQL-Präfix-Match — typsicher, GIN-indexierbar, konsistent mit dem bundesland-Backfill-Muster aus Plan 11-02" + - "CPV_DIVISIONS im Web als kleine Konstante gespiegelt (identisch zum BUNDESLAND_OPTIONS-Präzedenzfall aus Plan 11-02) statt Shared-Package" + - "Multi-Value-URL-Param (?cpv=45&cpv=71) statt Komma-Join — matcht Express' Default-Query-Parser 1:1, DTO @Transform normalisiert Einzelwert zu Array" + +key-files: + created: + - apps/api/src/tenders/cpv/cpv-catalog.ts + - apps/api/src/tenders/cpv/cpv-catalog.spec.ts + - apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql + modified: + - apps/api/src/tenders/tender-normalizer.service.ts + - apps/api/src/tenders/tender-normalizer.service.spec.ts + - apps/api/src/tenders/tender-ingestion.service.ts + - apps/api/src/tenders/tender.types.ts + - apps/api/prisma/schema.prisma + - apps/api/src/tenders/dto/tender-query.dto.ts + - apps/api/src/tenders/tender-query.builder.ts + - apps/api/src/tenders/tender-query.builder.spec.ts + - apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx + +key-decisions: + - "cpvDivisions ist ein reiner Divisions-Kurzkatalog (2-stellig, ~45 Einträge) — KEIN EU-CPV-Vollkatalog (Research Open Question 2, D-03). Labels aus Trainingswissen gegen die Standard-CPV-2008-Divisionsliste erstellt (RESEARCH Assumption A2, tertiäre Quelle) — beim nächsten Kontakt mit der offiziellen EU-CPV-Publikation gegenzuprüfen, falls Label-Genauigkeit kritisch wird." + - "normalizeCpv() nutzt einen einfachen führende-Ziffern-Regex (^\\d+) statt eine CPV-Prüfziffer-Semantik zu implementieren — für das Divisions-Level (führende 2 Ziffern) genügt das Abschneiden bei Nicht-Ziffern-Zeichen vollständig; eine echte Prüfziffernvalidierung wäre Over-Engineering für einen reinen Präfix-Filter." + - "cpv-URL-Param wird als wiederholter Key (?cpv=45&cpv=71) statt Komma-Join geschrieben — matcht Express' Default-Query-Parser direkt (kein Split/Join-Code nötig), DTO normalisiert per @Transform einen Einzelwert (?cpv=45 als String statt Array) zu einem Ein-Element-Array, damit eine einzelne Auswahl nicht an @IsArray scheitert." + - "CPV_DIVISION_OPTIONS im Web ist eine 1:1-Kopie von CPV_DIVISIONS (api) statt aus @tessera/shared bezogen — identische Begründung wie BUNDESLAND_OPTIONS in Plan 11-02 (apps/web nutzt @tessera/shared nicht, ~45-Werte-Katalog rechtfertigt keine neue Cross-Package-Abhängigkeit)." + +requirements-completed: [FILTER-03, FILTER-05] + +coverage: + - id: D1 + description: "normalizeCpv/divisionOf/cpvMatchesDivisions über alle drei Live-Format-Varianten ('45', '45000000', '45000000-7'); CPV_DIVISIONS-Katalog mit 45 Einträgen, deutschen Labels, keine Duplikate" + requirement: "FILTER-03" + verification: + - kind: unit + ref: "apps/api/src/tenders/cpv/cpv-catalog.spec.ts (17 tests)" + status: pass + human_judgment: false + - id: D2 + description: "TenderNormalizerService leitet cpvDivisions (distinct 2-stellige Divisionen) aus cpvCodes ab; leeres cpvCodes -> leeres cpvDivisions, nie ein Crash" + requirement: "FILTER-03" + verification: + - kind: unit + ref: "apps/api/src/tenders/tender-normalizer.service.spec.ts (10 tests, davon 2 neu für cpvDivisions)" + status: pass + human_judgment: false + - id: D3 + description: "Backfill-Migration füllt cpvDivisions für Bestandszeilen aus cpvCodes; GIN-Index ergänzt; idempotent (zweiter Lauf: UPDATE 0, ADD COLUMN/CREATE INDEX IF NOT EXISTS überspringen sauber)" + requirement: "FILTER-03" + verification: + - kind: manual_procedural + ref: "docker exec tessera-ctl-db-1 psql -U tessera -d tessera -f migration.sql -> ALTER TABLE, CREATE INDEX, UPDATE 1612 (erster Lauf); zweiter Lauf: NOTICE column already exists (skip), NOTICE index already exists (skip), UPDATE 0. Verifikation: cpv=['45']-Filter via cpvDivisions && ARRAY['45'] liefert 741 Zeilen inkl. aller drei Rohformate (175 Zeilen mit bloßem '45'); prisma migrate status -> up to date nach migrate resolve --applied" + status: pass + human_judgment: false + - id: D4 + description: "buildTenderWhere: cpv[] gesetzt -> cpvDivisions hasSome-Branch; mehrere Divisionen kombinierbar; leer/omitted fügt keinen AND-Branch hinzu" + requirement: "FILTER-03" + verification: + - kind: unit + ref: "apps/api/src/tenders/tender-query.builder.spec.ts (20 tests, davon 3 neu für cpv)" + status: pass + human_judgment: false + - id: D5 + description: "FilterPanel: CPV-Autocomplete (Label-/Code-Suche, Mehrfachauswahl-Chips mit Entfernen-Button) + Wert-min/max-Zahlenfelder + 'ohne Wertangabe einschließen'-Toggle (Default an, D-05 Pflichtkriterium) — schreibt cpv/valueMin/valueMax/includeNullValue in URL-searchParams" + requirement: "FILTER-05" + verification: [] + human_judgment: true + rationale: "Visuelle/Interaktions-Verifikation des Autocomplete-Dropdowns, der Chip-Entfernung und der resultierenden Trefferliste erfordert einen Docker-Rebuild + Browser-Check (Docker-Stack läuft noch mit alten Images, wie in 11-01/11-02 dokumentiert) — kein Component-Test in diesem Plan angefordert (Plan-Verify-Kommando zielt ausschließlich auf API-Vitest-Specs); TypeScript-Compile (tsc --noEmit) ist fehlerfrei in beiden Apps, bestehende Web-Component-Tests (ResultsList) bleiben grün." +--- + +# Phase 11 Plan 03: CPV-Divisions-Katalog, Backfill & Wert-Filter-UI Summary + +**Statischer 45-Divisionen-CPV-Katalog mit zweiseitiger Format-Normalisierung schließt Pitfall 2 (inkonsistente cpvCodes); precomputed `cpvDivisions`-Spalte (hasSome, GIN) ersetzt Raw-SQL; FilterPanel bekommt CPV-Autocomplete + den bislang backend-only Wert-min/max-Filter als UI mit "ohne Wertangabe einschließen"-Toggle (Default an).** + +## Performance + +- **Duration:** ~9 min +- **Started:** 2026-07-21T16:05:52+02:00 +- **Completed:** 2026-07-21T16:15:03+02:00 +- **Tasks:** 3 (Task 1 war TDD, RED-then-GREEN) +- **Files modified:** 12 (3 created, 9 modified) + +## Accomplishments +- `cpv/cpv-catalog.ts`: statischer 45-Einträge-CPV-Divisions-Katalog (2-stellig, deutsche Labels) + `normalizeCpv()` (führende-Ziffern-Regex, entfernt Prüfziffer/Suffix/Whitespace), `divisionOf()` (führende 2 Ziffern, funktioniert auf normalisierten UND rohen Codes) und `cpvMatchesDivisions()` — 17/17 Tests grün über alle drei Live-Format-Varianten ("45", "45000000", "45000000-7"). +- `TenderNormalizerService` leitet jetzt `cpvDivisions` (distinct 2-stellige Divisionen) aus `cpvCodes` ab; `tender-ingestion.service.ts` reicht das neue Feld an Prisma-`upsert` (create UND update) durch. +- `schema.prisma`: `Tender.cpvDivisions String[] @default([])` + `@@index([cpvDivisions], type: Gin)` ergänzt; Backfill-Migration `20260721150000_tender_cpv_divisions_backfill` lokal angewendet — **1612 von 1671 Zeilen** befüllt (59 Zeilen mit leerem `cpvCodes` bleiben `{}`, kein Datenverlust). Idempotenz verifiziert: zweiter Lauf `UPDATE 0`, `ADD COLUMN IF NOT EXISTS`/`CREATE INDEX IF NOT EXISTS` überspringen sauber. +- `buildTenderWhere` um einen konditionalen `cpvDivisions: { hasSome: dto.cpv }`-Branch erweitert (FILTER-03) — typsicher, kein Raw-SQL. +- `TenderQueryDto.cpv` (validiertes `string[]`, `@Transform` normalisiert einen einzelnen Query-String-Wert zu einem Ein-Element-Array, da Express nur bei wiederholtem Key ein Array liefert). +- `FilterPanel.tsx`: CPV-Autocomplete-Eingabefeld (Suche nach Code oder Label, Dropdown mit bis zu 8 Treffern, Mehrfachauswahl als entfernbare Chips) + Wert-min/max-Zahlenfelder + "ohne Wertangabe einschließen"-Checkbox (Default an) — schreibt `cpv` (wiederholter Key), `valueMin`, `valueMax`, `includeNullValue` in die URL-`searchParams`. +- Live-DB-Verifikation: `cpvDivisions && ARRAY['45']` liefert 741 Zeilen — darunter 175 Zeilen, deren `cpvCodes` NUR den bloßen Kurzcode `{45}` enthalten (kein 8-stelliger oder Prüfziffer-Code) — der Kern-Pitfall-2-Fall ist bestätigt gelöst. + +## Task Commits + +1. **Task 1a: CPV-Katalog RED** — `a64f984` (test) — failing `cpv-catalog.spec.ts` (17 Tests, Modul existiert noch nicht) +2. **Task 1b: CPV-Katalog GREEN** — `7651ab6` (feat) — `cpv-catalog.ts` implementiert, 17/17 Tests grün +3. **Task 2: cpvDivisions-Spalte + Normalizer + Backfill-Migration** — `f9591dc` (feat) — Normalizer-Erweiterung, Schema-Spalte + Gin-Index, Backfill-Migration lokal angewendet (1612/1671 Zeilen) +4. **Task 3: CPV- + Wert-Filter — Builder, DTO, FilterPanel** — `d60bfd1` (feat) — `cpv[]`-Branch im Builder, DTO-Feld, FilterPanel-Autocomplete + Wert-min/max-UI + +_Task 1 war TDD (RED-then-GREEN); Task 2 und 3 waren `type="auto"` ohne separaten RED-Schritt._ + +## Files Created/Modified +- `apps/api/src/tenders/cpv/cpv-catalog.ts` - statischer Divisions-Katalog + Normalisierungs-Helfer (neu) +- `apps/api/src/tenders/cpv/cpv-catalog.spec.ts` - 17 Unit-Tests (neu) +- `apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql` - Spalte + Gin-Index + idempotenter Backfill-UPDATE (neu) +- `apps/api/src/tenders/tender-normalizer.service.ts` - `cpvDivisions` aus `cpvCodes` abgeleitet +- `apps/api/src/tenders/tender-normalizer.service.spec.ts` - 2 neue Testfälle (cpvDivisions-Ableitung, leeres cpvCodes) +- `apps/api/src/tenders/tender-ingestion.service.ts` - `cpvDivisions` in Upsert create+update durchgereicht +- `apps/api/src/tenders/tender.types.ts` - `NormalizedTenderFields.cpvDivisions` ergänzt +- `apps/api/prisma/schema.prisma` - `cpvDivisions`-Spalte + Gin-Index +- `apps/api/src/tenders/dto/tender-query.dto.ts` - `cpv`-Feld (validiert, transformiert) +- `apps/api/src/tenders/tender-query.builder.ts` - `cpvDivisions`-hasSome-Branch +- `apps/api/src/tenders/tender-query.builder.spec.ts` - 3 neue Testfälle (cpv einzeln/mehrfach/leer) +- `apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx` - CPV-Autocomplete + Wert-min/max-UI + includeNullValue-Toggle + +## Decisions Made +- Kurzkatalog (2-stellig, ~45 Einträge) statt EU-Vollkatalog — bestätigt durch Research Open Question 2 (RESOLVED) und explizite Plan-Vorgabe (D-03). +- CPV-Divisions-Labels aus Trainingswissen gegen die Standard-CPV-2008-Divisionsliste zusammengestellt (RESEARCH Assumption A2, tertiäre Quelle) — sollte bei zukünftigem Kontakt mit der offiziellen EU-CPV-Publikation gegengeprüft werden, falls Label-Exaktheit kritisch wird (aktuell kein Blocker: die Codes selbst sind korrekt/stabil, nur die Formulierung der Labels ist die unsichere Dimension). +- `cpv`-URL-Param als wiederholter Key statt Komma-Join — reduziert Custom-Split/Join-Code auf beiden Seiten und matcht Express' Default-Verhalten; DTO-`@Transform` fängt den Einzelwert-Fall ab. +- CPV_DIVISION_OPTIONS im Web 1:1 aus `apps/api/src/tenders/cpv/cpv-catalog.ts` gespiegelt statt Shared-Package — identisches Muster/Begründung wie `BUNDESLAND_OPTIONS` (Plan 11-02). + +## Deviations from Plan + +None - plan executed exactly as written. Eine kleine, im Plan bereits erwartete Doku-Korrektur: der veraltete Kommentar in `tender-query.dto.ts` ("CPV/favOnly filters are added in later Phase-11 plans (11-03/05)") wurde auf "favOnly filter is added in a later Phase-11 plan (11-05)" aktualisiert, da `cpv` in diesem Plan nun implementiert ist (Rule 1 — Doku-Inkonsistenz, keine funktionale Änderung, im selben Commit wie die DTO-Erweiterung). + +## Issues Encountered +- Nach der Schema-Erweiterung (`cpvDivisions`-Spalte) schlug `tsc --noEmit` zunächst mit "Object literal may only specify known properties" in `tender-ingestion.service.ts` fehl, weil der generierte Prisma-Client noch den alten Stand hatte — behoben durch `prisma generate` nach der Migration (kein Auto-Fix-Regelverstoß, erwarteter Zwischenschritt der Migrations-Reihenfolge). +- Der erste `migration.sql`-Testlauf (`ALTER TABLE ADD COLUMN` ohne `IF NOT EXISTS`) scheiterte beim zweiten manuellen Ausführen mit "column already exists" — vor dem finalen Commit auf `ADD COLUMN IF NOT EXISTS` korrigiert, um echte Idempotenz herzustellen (die im Plan explizit als Anforderung genannt ist) und konsistent mit dem `CREATE INDEX IF NOT EXISTS`-Muster aus der 11-02-Migration zu bleiben. + +## User Setup Required + +None - keine externen Service-Konfigurationen erforderlich. Keine neuen npm-Pakete (Package Legitimacy Audit: keine Neuinstallation, CPV-Katalog ist eine statische, repo-committete Datei). + +## Next Phase Readiness + +- FILTER-03 (CPV hierarchisch + Autocomplete, format-robust) und FILTER-05 (Wert min/max UI, NULL-graceful) sind end-to-end abgeschlossen — ROADMAP-Erfolgskriterium 1 (Liste + Filter) ist damit vollständig abgedeckt (alle Filter-Dimensionen aus D-01..D-05 implementiert über Pläne 11-01/02/03). +- `buildTenderWhere` hat jetzt sechs unabhängige, additiv kombinierbare AND-Branches (q, openOnly/deadline, value, plz/bundesland/region, cpv) — Plan 11-04 (Detailansicht) und 11-05 (Triage) fügen keine weiteren Query-Filter-Branches hinzu, sondern neue Endpunkte/Komponenten. +- **Manuelle UAT ausstehend** (wie in 11-01/11-02 SUMMARY dokumentiert): Der Docker-Stack läuft noch mit den alten Images. Für eine Live-Verifikation im Browser (CPV-Autocomplete "Bau" eingeben → Division 45 auswählen → Trefferliste filtert inkl. Zeilen mit rohem "45"; Wertfilter min setzen bei aktivem Toggle → "keine Wertangabe"-Zeilen bleiben sichtbar) ist `docker compose build api web && docker compose up -d` erforderlich (User führt den Rebuild selbst aus, kein Docker-Deploy durch Claude — projektinterne Regel). Alle automatisierten Tests sind grün: 128/128 API-Tests (`pnpm --filter @tessera/api test`), 7/7 Web-Tests für das tender-radar-Modul; `tsc --noEmit` fehlerfrei in beiden Apps. + +--- +*Phase: 11-filter-engine-results-ui-saved-searches* +*Completed: 2026-07-21* + +## Self-Check: PASSED + +All 10 created/modified files verified present on disk; all 4 task commits (a64f984, 7651ab6, f9591dc, d60bfd1) verified in git log.