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:
@@ -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';
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Ausschreibungs-Radar
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ausschreibungen werden erfasst.
|
||||
</p>
|
||||
</div>
|
||||
<CoverageBanner />
|
||||
<FilterPanel />
|
||||
<ResultsList />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user