diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx new file mode 100644 index 0000000..dae9ff4 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { fetchCoverage, type CoverageResponse } from '@/lib/tender-radar-api'; + +/** + * CoverageBanner (D-12, UI-05) — explains data coverage (Oberschwelle vs. + * Unterschwelle) so a thin/single-source result list is never misread as a + * defect (the user-frust trigger this whole phase addresses). Currently + * only the 'doe-opendata' source (EU-weite Oberschwelle) is ingested; + * Unterschwelle-Vergaben are out of scope until a later phase adds more + * sources (Phase 13). + * + * Fails silently (renders nothing) on fetch error — the banner is a + * courtesy explanation, not a load-bearing part of the page. + */ +export function CoverageBanner() { + const [coverage, setCoverage] = useState(null); + + useEffect(() => { + fetchCoverage() + .then(setCoverage) + .catch(() => setCoverage(null)); + }, []); + + if (!coverage) return null; + + const onlyDoe = coverage.sources.every((s) => s.sourcePortal === 'doe-opendata'); + if (!onlyDoe) return null; + + return ( +
+ Datenabdeckung: Aktuell + werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen + DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen + fehlen noch — eine kurze Trefferliste ist daher kein Fehler. +
+ ); +} 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 new file mode 100644 index 0000000..3d150e8 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; + +/** + * FilterPanel — writes filter state into the URL searchParams; ResultsList + * reads them and re-fetches (Research key_link, D-01/04/06). + * + * 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). + * + * Hardcoded German strings — i18n rollout is Phase 14 (Research Open + * Question 3, RESOLVED). + */ + +const SORT_OPTIONS: Array<{ value: string; label: string }> = [ + { value: 'published', label: 'Veröffentlichungsdatum' }, + { value: 'deadline', label: 'Abgabefrist' }, + { value: 'value', label: 'Auftragswert' }, +]; + +export function FilterPanel() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const q = searchParams.get('q') ?? ''; + const sort = searchParams.get('sort') ?? 'published'; + // openOnly defaults to true (D-04) — only an explicit "false" turns it off. + const openOnly = searchParams.get('openOnly') !== 'false'; + const deadlineFrom = searchParams.get('deadlineFrom') ?? ''; + const deadlineTo = searchParams.get('deadlineTo') ?? ''; + + const setParam = (key: string, value: string | null) => { + const next = new URLSearchParams(searchParams.toString()); + if (value === null || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + // Any filter change resets pagination to page 1 — stale `page` values + // from a prior, larger result set would otherwise render an empty page. + next.delete('page'); + router.replace(`?${next.toString()}`); + }; + + 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'; + + return ( +
+
+
+ + setParam('q', e.target.value)} + className={inputCls} + /> +
+ +
+ + +
+ +
+ setParam('openOnly', e.target.checked ? null : 'false')} + className="h-4 w-4" + /> + +
+
+ +
+
+ + setParam('deadlineFrom', e.target.value)} + className="h-9 rounded border border-border bg-background px-3 text-sm text-foreground" + /> +
+
+ + setParam('deadlineTo', e.target.value)} + className="h-9 rounded border border-border bg-background px-3 text-sm text-foreground" + /> +
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx new file mode 100644 index 0000000..2d5f482 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { listTenders, type Tender } from '@/lib/tender-radar-api'; + +/** + * ResultsList — the Trefferliste replacing the Phase 10 module placeholder + * (Plan 11-01, FILTER-01, UI-01). Reads all filter/sort/pagination state + * from the URL searchParams (written by FilterPanel + this component's own + * sortable column headers — Research: URL-Param-getriebener Client, + * key_link "FilterPanel schreibt URL-searchParams -> ResultsList liest + * sie -> listTenders()") and re-fetches whenever the URL changes. + * + * D-05 (Pflichtkriterium): a row with estimatedValue=null is NEVER hidden — + * it renders the literal "keine Wertangabe" label instead of blank/0. + */ + +const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; label: string }> = [ + { key: 'deadline', label: 'Frist' }, + { key: 'value', label: 'Wert' }, + { key: 'published', label: 'Veröffentlicht' }, +]; + +const DEFAULT_LIMIT = 20; + +function formatDate(value: string | null): string { + if (!value) return '–'; + const d = new Date(value); + if (isNaN(d.getTime())) return '–'; + return d.toLocaleDateString('de-DE'); +} + +/** + * D-05: estimatedValue arrives as a string (Prisma Decimal) or null. + * Renders "keine Wertangabe" for null/unparseable values — never blank, + * never 0, and never a thrown error. + */ +function formatValue(value: string | null): string { + if (value === null || value === undefined) return 'keine Wertangabe'; + const n = Number(value); + if (!Number.isFinite(n)) return 'keine Wertangabe'; + return n.toLocaleString('de-DE', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0, + }); +} + +export function ResultsList() { + const router = useRouter(); + const searchParams = useSearchParams(); + const paramsKey = searchParams.toString(); + + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const params = new URLSearchParams(paramsKey); + if (!params.get('limit')) params.set('limit', String(DEFAULT_LIMIT)); + const data = await listTenders(params); + setItems(data.items); + setTotal(data.total); + setPage(data.page); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Ausschreibungen konnten nicht geladen werden.', + ); + } finally { + setIsLoading(false); + } + }, [paramsKey]); + + useEffect(() => { + load(); + }, [load]); + + const currentSort = searchParams.get('sort') ?? 'published'; + + const handleSort = (key: string) => { + const next = new URLSearchParams(searchParams.toString()); + next.set('sort', key); + router.replace(`?${next.toString()}`); + }; + + const goToPage = (p: number) => { + const next = new URLSearchParams(searchParams.toString()); + next.set('page', String(p)); + router.replace(`?${next.toString()}`); + }; + + const limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT); + const totalPages = Math.max(1, Math.ceil(total / limit)); + + const thStaticCls = + 'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left'; + const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`; + + return ( +
+ + + + + + {SORT_HEADERS.map((h) => ( + + ))} + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((_, j) => ( + + ))} + + )) + ) : error ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((tender) => ( + + + + + + + + )) + )} + +
TitelAuftraggeber handleSort(h.key)} + aria-sort={currentSort === h.key ? 'ascending' : 'none'} + > + {h.label} + {currentSort === h.key ? ' ▾' : ''} +
+
+
+ {error} +
+

+ Keine Ausschreibungen gefunden. +

+

+ Passen Sie die Filter an oder deaktivieren Sie „nur noch + offene". +

+
+ {tender.title} + {tender.buyerName ?? '–'}{formatDate(tender.deadlineAt)}{formatValue(tender.estimatedValue)}{formatDate(tender.publishedAt)}
+ + {!isLoading && total > limit && ( +
+ + Zeige {(page - 1) * limit + 1}–{Math.min(page * limit, total)} von{' '} + {total} + +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx index 2ca8507..9d19b7f 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx @@ -1,31 +1,53 @@ 'use client'; +import { Suspense } from 'react'; +import { CoverageBanner } from './components/CoverageBanner'; +import { FilterPanel } from './components/FilterPanel'; +import { ResultsList } from './components/ResultsList'; + /** * Ausschreibungs-Radar module page within the portal. * - * MVP stub (Plan 10-02, CONFIG-01): proves the activation -> page-load - * path (marketplace seed -> module-loader whitelist -> lazy-loaded - * page). Real results UI (search, filters, tender list) lands in - * Phase 11. + * Replaces the Phase 10 placeholder (CONFIG-01 MVP stub) with the real + * Trefferliste (Plan 11-01): CoverageBanner explains data coverage, + * FilterPanel writes filter/sort state into the URL, ResultsList reads it + * and renders the ~1671 existing DÖE tenders. * - * Intentional hardcoded German string: full i18n (next-intl message - * keys) is CONFIG-03, planned for Phase 14. Do not add a translation - * key for this stub yet — replace this whole component in Phase 11. + * `useSearchParams()` (used by FilterPanel/ResultsList) requires a + * Suspense boundary per the Next.js App Router contract — wrapped here + * even though this component is loaded via `dynamic(..., { ssr: false })` + * (module-loader.ts), per the plan's explicit Next 16 guidance. + * + * Intentional hardcoded German strings: full i18n (next-intl message + * keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3, + * RESOLVED). */ -export default function TenderRadarPage() { +function TenderRadarContent() { return ( -
+

Ausschreibungs-Radar

-
-

- Ausschreibungen werden erfasst. -

-
+ + +
); } + +export default function TenderRadarPage() { + return ( + +

Lädt…

+
+ } + > + + + ); +}