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