feat(11-04): TenderDetail component with ?tender=<id> wiring
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=<id> 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=<id>" — 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) <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,16 @@ export function ResultsList() {
|
|||||||
router.replace(`?${next.toString()}`);
|
router.replace(`?${next.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the TenderDetail overlay (Plan 11-04, UI-02) by setting
|
||||||
|
* `?tender=<id>` — 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 limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT);
|
||||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
@@ -157,7 +167,8 @@ export function ResultsList() {
|
|||||||
items.map((tender) => (
|
items.map((tender) => (
|
||||||
<tr
|
<tr
|
||||||
key={tender.id}
|
key={tender.id}
|
||||||
className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
|
onClick={() => openDetail(tender.id)}
|
||||||
|
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium text-foreground">
|
<td className="px-4 py-3 font-medium text-foreground">
|
||||||
{tender.title}
|
{tender.title}
|
||||||
|
|||||||
@@ -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=<id>` 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<Tender | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex justify-end bg-black/40"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Ausschreibungsdetails"
|
||||||
|
>
|
||||||
|
{/* Backdrop click closes the drawer, mirroring standard overlay UX */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-default"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative h-full w-full max-w-lg overflow-y-auto border-l border-border bg-background p-6 shadow-xl">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold tracking-tight">
|
||||||
|
Ausschreibungsdetails
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Lädt…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
) : tender ? (
|
||||||
|
<dl className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Titel</dt>
|
||||||
|
<dd className="mt-1 font-medium text-foreground">
|
||||||
|
{tender.title}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Auftraggeber</dt>
|
||||||
|
<dd className="mt-1">{tender.buyerName ?? '–'}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Region / PLZ / Bundesland</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{[tender.region, tender.plz, tender.bundesland]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') || '–'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>CPV-Codes</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{tender.cpvCodes.length > 0
|
||||||
|
? tender.cpvCodes.join(', ')
|
||||||
|
: 'keine CPV-Codes angegeben'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Abgabefrist</dt>
|
||||||
|
<dd className="mt-1">{formatDeadline(tender.deadlineAt)}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Geschätzter Auftragswert</dt>
|
||||||
|
<dd className="mt-1">{formatValue(tender.estimatedValue)}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Verfahrensart</dt>
|
||||||
|
<dd className="mt-1">{tender.procedureType ?? '–'}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Status</dt>
|
||||||
|
<dd className="mt-1">{tender.status}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className={fieldLabelCls()}>Veröffentlicht</dt>
|
||||||
|
<dd className="mt-1">{formatPublished(tender.publishedAt)}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-border bg-muted/30 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Vergabeunterlagen werden nicht lokal gespiegelt — der
|
||||||
|
folgende Link führt direkt zum Original auf dem
|
||||||
|
Quellportal.
|
||||||
|
</p>
|
||||||
|
{tender.sourceUrl ? (
|
||||||
|
<a
|
||||||
|
href={tender.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Zur Ausschreibung auf dem Quellportal →
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Kein Quell-Link verfügbar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { CoverageBanner } from './components/CoverageBanner';
|
import { CoverageBanner } from './components/CoverageBanner';
|
||||||
import { FilterPanel } from './components/FilterPanel';
|
import { FilterPanel } from './components/FilterPanel';
|
||||||
import { ResultsList } from './components/ResultsList';
|
import { ResultsList } from './components/ResultsList';
|
||||||
|
import { TenderDetail } from './components/TenderDetail';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ausschreibungs-Radar module page within the portal.
|
* 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
|
* FilterPanel writes filter/sort state into the URL, ResultsList reads it
|
||||||
* and renders the ~1671 existing DÖE tenders.
|
* and renders the ~1671 existing DÖE tenders.
|
||||||
*
|
*
|
||||||
* `useSearchParams()` (used by FilterPanel/ResultsList) requires a
|
* Plan 11-04 (UI-02, D-07): the `?tender=<id>` URL param drives an
|
||||||
* Suspense boundary per the Next.js App Router contract — wrapped here
|
* in-component detail view (TenderDetail) rendered as an overlay on top
|
||||||
* even though this component is loaded via `dynamic(..., { ssr: false })`
|
* of the list — deliberately NOT a separate Next.js sub-route, since this
|
||||||
* (module-loader.ts), per the plan's explicit Next 16 guidance.
|
* 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
|
* Intentional hardcoded German strings: full i18n (next-intl message
|
||||||
* keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3,
|
* keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3,
|
||||||
* RESOLVED).
|
* RESOLVED).
|
||||||
*/
|
*/
|
||||||
function TenderRadarContent() {
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -34,6 +54,8 @@ function TenderRadarContent() {
|
|||||||
<CoverageBanner />
|
<CoverageBanner />
|
||||||
<FilterPanel />
|
<FilterPanel />
|
||||||
<ResultsList />
|
<ResultsList />
|
||||||
|
|
||||||
|
{tenderId && <TenderDetail tenderId={tenderId} onClose={closeDetail} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,3 +136,18 @@ export async function fetchCoverage(): Promise<CoverageResponse> {
|
|||||||
if (!res.ok) throw new Error('Failed to fetch tender-radar coverage');
|
if (!res.ok) throw new Error('Failed to fetch tender-radar coverage');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single tender's full detail (Plan 11-04, UI-02/D-07) for the
|
||||||
|
* `?tender=<id>` 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<Tender> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user