From 653c63b072084ee6f6a61848816369460b83bd2d Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 16:21:54 +0200 Subject: [PATCH] feat(11-04): TenderDetail component with ?tender= wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds getTender() to the api client and a self-fetching TenderDetail overlay (pattern: SourceConfigForm) rendering all Tender fields plus a safe (rel=noopener noreferrer, target=_blank) sourceUrl link. No local mirroring of Vergabeunterlagen — rawPayload is 100% NULL in the live DB (research finding), so only the source link exists; NULL value/deadline render graceful German placeholders (D-05 applies to the detail view too). page.tsx reads ?tender= via useSearchParams and renders TenderDetail as an overlay; closing removes the param. ResultsList row clicks set the param (deviation: ResultsList.tsx was not listed in the plan's files_modified but is required by the plan's own done-criteria/key_link "ResultsList-Zeile setzt ?tender=" — Rule 3 auto-fix, blocking). 3/3 TenderDetail tests pass; full web suite (117 tests) and tsc --noEmit both clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tender-radar/components/ResultsList.tsx | 13 +- .../tender-radar/components/TenderDetail.tsx | 216 ++++++++++++++++++ .../(portal)/modules/tender-radar/page.tsx | 30 ++- apps/web/src/lib/tender-radar-api.ts | 15 ++ 4 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx 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 index 2d5f482..256c664 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx @@ -97,6 +97,16 @@ export function ResultsList() { router.replace(`?${next.toString()}`); }; + /** + * Opens the TenderDetail overlay (Plan 11-04, UI-02) by setting + * `?tender=` — page.tsx reads this param and renders TenderDetail. + */ + const openDetail = (id: string) => { + const next = new URLSearchParams(searchParams.toString()); + next.set('tender', id); + router.replace(`?${next.toString()}`); + }; + const limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT); const totalPages = Math.max(1, Math.ceil(total / limit)); @@ -157,7 +167,8 @@ export function ResultsList() { items.map((tender) => ( openDetail(tender.id)} + className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors" > {tender.title} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx new file mode 100644 index 0000000..09a35c4 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getTender, type Tender } from '@/lib/tender-radar-api'; + +/** + * TenderDetail — In-Component-Detailansicht für `?tender=` innerhalb + * der Modulseite (Plan 11-04, UI-02). Bewusst KEINE eigene Next.js- + * Unterroute — das Modul ist als `dynamic(..., { ssr: false })`-Komponente + * unter `[category]/[moduleSlug]` gemountet (Anti-Pattern/Assumption A5), + * eine echte Unterroute würde diesen Mount-Mechanismus umgehen. + * + * Selbstständig fetchend (Muster: SourceConfigForm.tsx) — `page.tsx` reicht + * nur die `tenderId` aus dem URL-Param + `onClose` durch, der eigentliche + * `getTender`-Aufruf und dessen Ladezustand leben hier. + * + * D-07/UI-02 — zeigt alle vorhandenen Tender-Felder + einen Link zur + * Quelle (`sourceUrl`). `rawPayload` ist zu 100% NULL in der Live-DB + * (Research OQ1) — es existieren KEINE gespeicherten Dokument-URLs. Daher: + * KEIN Live-Dokument-Fetch, KEIN lokales Spiegeln der Vergabeunterlagen — + * nur der Quell-Link führt zum Original (T-11-09: `rel="noopener + * noreferrer"` + `target="_blank"` gegen Tampering über den externen Link). + * + * Hardcoded German strings — i18n rollout is Phase 14 (Research Open + * Question 3, RESOLVED), same convention as ResultsList/FilterPanel. + */ + +interface TenderDetailProps { + tenderId: string; + onClose: () => void; +} + +/** D-05 gilt auch für die Detailansicht: NIE ausblenden, NIE Fehler werfen. */ +function formatDeadline(value: string | null): string { + if (!value) return 'keine Frist angegeben'; + const d = new Date(value); + if (isNaN(d.getTime())) return 'keine Frist angegeben'; + return d.toLocaleDateString('de-DE'); +} + +/** + * `estimatedValue` kommt als String (Prisma Decimal) oder null — niemals + * blind `Number()`-koerzieren (gleiche Regel wie ResultsList.formatValue). + */ +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, + }); +} + +function formatPublished(value: string): string { + const d = new Date(value); + if (isNaN(d.getTime())) return '–'; + return d.toLocaleDateString('de-DE'); +} + +function fieldLabelCls() { + return 'text-xs font-semibold uppercase tracking-wider text-muted-foreground'; +} + +export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { + const [tender, setTender] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(null); + setTender(null); + + getTender(tenderId) + .then((data) => { + if (!cancelled) setTender(data); + }) + .catch((err) => { + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : 'Ausschreibung konnte nicht geladen werden.', + ); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [tenderId]); + + return ( +
+ {/* Backdrop click closes the drawer, mirroring standard overlay UX */} + +
+ + {isLoading ? ( +

Lädt…

+ ) : error ? ( +

{error}

+ ) : tender ? ( +
+
+
Titel
+
+ {tender.title} +
+
+ +
+
Auftraggeber
+
{tender.buyerName ?? '–'}
+
+ +
+
Region / PLZ / Bundesland
+
+ {[tender.region, tender.plz, tender.bundesland] + .filter(Boolean) + .join(' · ') || '–'} +
+
+ +
+
CPV-Codes
+
+ {tender.cpvCodes.length > 0 + ? tender.cpvCodes.join(', ') + : 'keine CPV-Codes angegeben'} +
+
+ +
+
Abgabefrist
+
{formatDeadline(tender.deadlineAt)}
+
+ +
+
Geschätzter Auftragswert
+
{formatValue(tender.estimatedValue)}
+
+ +
+
Verfahrensart
+
{tender.procedureType ?? '–'}
+
+ +
+
Status
+
{tender.status}
+
+ +
+
Veröffentlicht
+
{formatPublished(tender.publishedAt)}
+
+ +
+

+ Vergabeunterlagen werden nicht lokal gespiegelt — der + folgende Link führt direkt zum Original auf dem + Quellportal. +

+ {tender.sourceUrl ? ( + + Zur Ausschreibung auf dem Quellportal → + + ) : ( +

+ Kein Quell-Link verfügbar. +

+ )} +
+
+ ) : null} + + + ); +} 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 9d19b7f..85721b9 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx @@ -1,9 +1,11 @@ 'use client'; import { Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { CoverageBanner } from './components/CoverageBanner'; import { FilterPanel } from './components/FilterPanel'; import { ResultsList } from './components/ResultsList'; +import { TenderDetail } from './components/TenderDetail'; /** * Ausschreibungs-Radar module page within the portal. @@ -13,16 +15,34 @@ import { ResultsList } from './components/ResultsList'; * FilterPanel writes filter/sort state into the URL, ResultsList reads it * and renders the ~1671 existing DÖE tenders. * - * `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. + * Plan 11-04 (UI-02, D-07): the `?tender=` URL param drives an + * in-component detail view (TenderDetail) rendered as an overlay on top + * of the list — deliberately NOT a separate Next.js sub-route, since this + * page is mounted via `dynamic(..., { ssr: false })` under + * `[category]/[moduleSlug]` (Anti-Pattern/Assumption A5). A row click in + * ResultsList sets the param; closing the detail removes it. + * + * `useSearchParams()` (used by FilterPanel/ResultsList/this component) + * 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). */ function TenderRadarContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const tenderId = searchParams.get('tender'); + + const closeDetail = () => { + const next = new URLSearchParams(searchParams.toString()); + next.delete('tender'); + router.replace(`?${next.toString()}`); + }; + return (
@@ -34,6 +54,8 @@ function TenderRadarContent() { + + {tenderId && }
); } diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index e4b8815..9209f1f 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -136,3 +136,18 @@ export async function fetchCoverage(): Promise { if (!res.ok) throw new Error('Failed to fetch tender-radar coverage'); return res.json(); } + +/** + * Fetch a single tender's full detail (Plan 11-04, UI-02/D-07) for the + * `?tender=` in-component detail view. `rawPayload` is 100% NULL in + * the live DB (Research) — no stored document URLs exist, so the detail + * view only ever links to `sourceUrl`; there is no live document fetch. + * GET /modules/tender-radar/:id + */ +export async function getTender(id: string): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/${id}`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch tender detail'); + return res.json(); +}