feat(11-01): replace tender-radar placeholder with real results UI (GREEN)

Replaces the Phase 10 module stub with a Master-Container page.tsx
(Suspense-wrapped for useSearchParams, Next 16 App Router) rendering
CoverageBanner + FilterPanel + ResultsList.

- ResultsList: URL-param-driven fetch via listTenders(), sortable
  Frist/Wert/Veröffentlicht column headers, "keine Wertangabe" for
  estimatedValue=null rows (D-05), pagination.
- FilterPanel: Freitext (q), Sortierung, "nur noch offene" toggle
  (openOnly, default on), Abgabefrist von/bis date inputs — param names
  match the TenderQueryDto field names 1:1 for the Plan 11-06
  Saved-Search serialization contract.
- CoverageBanner: German coverage hint shown while GET /coverage
  reports only the doe-opendata source (D-12, UI-05).

All strings hardcoded German (i18n = Phase 14). Plain fetch throughout,
no TanStack Query (not installed, per RESEARCH Open Question 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 15:54:51 +02:00
parent b55bb55c0e
commit ee2c40863e
4 changed files with 407 additions and 14 deletions
@@ -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<CoverageResponse | null>(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 (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<strong className="text-foreground">Datenabdeckung:</strong> 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.
</div>
);
}
@@ -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 (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}>
Suche
</label>
<input
id="tr-filter-q"
type="text"
placeholder="Titel oder Auftraggeber…"
value={q}
onChange={(e) => setParam('q', e.target.value)}
className={inputCls}
/>
</div>
<div>
<label htmlFor="tr-filter-sort" className={labelCls}>
Sortierung
</label>
<select
id="tr-filter-sort"
value={sort}
onChange={(e) => setParam('sort', e.target.value)}
className="h-9 rounded border border-border bg-background px-3 text-sm text-foreground"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 pb-2">
<input
id="tr-filter-open-only"
type="checkbox"
checked={openOnly}
onChange={(e) => setParam('openOnly', e.target.checked ? null : 'false')}
className="h-4 w-4"
/>
<label htmlFor="tr-filter-open-only" className="text-sm text-foreground">
Nur noch offene
</label>
</div>
</div>
<div className="flex flex-wrap items-end gap-4">
<div>
<label htmlFor="tr-filter-deadline-from" className={labelCls}>
Abgabefrist von
</label>
<input
id="tr-filter-deadline-from"
type="date"
value={deadlineFrom}
onChange={(e) => setParam('deadlineFrom', e.target.value)}
className="h-9 rounded border border-border bg-background px-3 text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="tr-filter-deadline-to" className={labelCls}>
Abgabefrist bis
</label>
<input
id="tr-filter-deadline-to"
type="date"
value={deadlineTo}
onChange={(e) => setParam('deadlineTo', e.target.value)}
className="h-9 rounded border border-border bg-background px-3 text-sm text-foreground"
/>
</div>
</div>
</div>
);
}
@@ -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<Tender[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/30">
<tr>
<th className={thStaticCls}>Titel</th>
<th className={thStaticCls}>Auftraggeber</th>
{SORT_HEADERS.map((h) => (
<th
key={h.key}
className={thSortCls}
onClick={() => handleSort(h.key)}
aria-sort={currentSort === h.key ? 'ascending' : 'none'}
>
{h.label}
{currentSort === h.key ? ' ▾' : ''}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border last:border-0">
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 rounded bg-muted animate-pulse" />
</td>
))}
</tr>
))
) : error ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-sm text-destructive">
{error}
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center">
<p className="font-medium text-foreground">
Keine Ausschreibungen gefunden.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Passen Sie die Filter an oder deaktivieren Sie „nur noch
offene".
</p>
</td>
</tr>
) : (
items.map((tender) => (
<tr
key={tender.id}
className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
>
<td className="px-4 py-3 font-medium text-foreground">
{tender.title}
</td>
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
</tr>
))
)}
</tbody>
</table>
{!isLoading && total > limit && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-sm text-muted-foreground">
Zeige {(page - 1) * limit + 1}–{Math.min(page * limit, total)} von{' '}
{total}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => goToPage(page - 1)}
disabled={page <= 1}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
← Zurück
</button>
<button
type="button"
onClick={() => goToPage(page + 1)}
disabled={page >= totalPages}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter →
</button>
</div>
</div>
)}
</div>
);
}
@@ -1,31 +1,53 @@
'use client'; '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. * Ausschreibungs-Radar module page within the portal.
* *
* MVP stub (Plan 10-02, CONFIG-01): proves the activation -> page-load * Replaces the Phase 10 placeholder (CONFIG-01 MVP stub) with the real
* path (marketplace seed -> module-loader whitelist -> lazy-loaded * Trefferliste (Plan 11-01): CoverageBanner explains data coverage,
* page). Real results UI (search, filters, tender list) lands in * FilterPanel writes filter/sort state into the URL, ResultsList reads it
* Phase 11. * and renders the ~1671 existing DÖE tenders.
* *
* Intentional hardcoded German string: full i18n (next-intl message * `useSearchParams()` (used by FilterPanel/ResultsList) requires a
* keys) is CONFIG-03, planned for Phase 14. Do not add a translation * Suspense boundary per the Next.js App Router contract — wrapped here
* key for this stub yet — replace this whole component in Phase 11. * 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 ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-6">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Ausschreibungs-Radar Ausschreibungs-Radar
</h1> </h1>
</div> </div>
<div className="rounded-lg border border-border bg-card p-6 shadow-sm"> <CoverageBanner />
<p className="text-sm text-muted-foreground"> <FilterPanel />
Ausschreibungen werden erfasst. <ResultsList />
</p>
</div>
</div> </div>
); );
} }
export default function TenderRadarPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-5xl space-y-6 p-6">
<p className="text-sm text-muted-foreground">Lädt…</p>
</div>
}
>
<TenderRadarContent />
</Suspense>
);
}