diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx index 5e792bb..1a1c173 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx @@ -94,6 +94,87 @@ describe('TenderDetail', () => { expect(screen.getByText('keine Frist angegeben')).toBeInTheDocument(); }); + it('renders a link for every source when tender.sources[] has multiple entries (13-06, SCHEMA-03)', async () => { + mockGetTender.mockResolvedValue({ + id: 't4', + sourcePortal: 'doe-opendata', + title: 'Neubau Kita', + buyerName: 'Gemeinde Beispiel', + cpvCodes: ['45214100'], + region: null, + plz: null, + bundesland: null, + deadlineAt: null, + estimatedValue: null, + procedureType: null, + status: 'active', + sourceUrl: 'https://example-portal.de/ausschreibung/t4', + publishedAt: '2026-07-01T00:00:00.000Z', + sources: [ + { + sourcePortal: 'doe-opendata', + sourceUrl: 'https://doe.example/t4', + sourceNoticeId: 'doe-t4', + }, + { + sourcePortal: 'cosinex-dtvp', + sourceUrl: 'https://dtvp.example/t4', + sourceNoticeId: 'dtvp-t4', + }, + ], + }); + + const { TenderDetail } = await import('./TenderDetail'); + render( {}} />); + + await waitFor(() => { + expect(screen.getByText('Neubau Kita')).toBeInTheDocument(); + }); + + const links = screen.getAllByRole('link', { name: /Quellportal/i }); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute('href', 'https://doe.example/t4'); + expect(links[0]).toHaveTextContent('DÖE'); + expect(links[0]).toHaveAttribute('target', '_blank'); + expect(links[0]).toHaveAttribute('rel', 'noopener noreferrer'); + expect(links[1]).toHaveAttribute('href', 'https://dtvp.example/t4'); + expect(links[1]).toHaveTextContent('DTVP'); + }); + + it('falls back to the single sourceUrl block when tender.sources is missing/empty', async () => { + mockGetTender.mockResolvedValue({ + id: 't5', + sourcePortal: 'doe-opendata', + title: 'Sanierung Brücke', + buyerName: null, + cpvCodes: [], + region: null, + plz: null, + bundesland: null, + deadlineAt: null, + estimatedValue: null, + procedureType: null, + status: 'active', + sourceUrl: 'https://example-portal.de/ausschreibung/t5', + publishedAt: '2026-07-01T00:00:00.000Z', + sources: [], + }); + + const { TenderDetail } = await import('./TenderDetail'); + render( {}} />); + + await waitFor(() => { + expect(screen.getByText('Sanierung Brücke')).toBeInTheDocument(); + }); + + const links = screen.getAllByRole('link', { name: /Quellportal/i }); + expect(links).toHaveLength(1); + expect(links[0]).toHaveAttribute( + 'href', + 'https://example-portal.de/ausschreibung/t5', + ); + }); + it('always shows the no-local-mirroring notice for Vergabeunterlagen', async () => { mockGetTender.mockResolvedValue({ id: 't3', 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 index 09a35c4..5d376ae 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx @@ -14,12 +14,18 @@ import { getTender, type Tender } from '@/lib/tender-radar-api'; * 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). + * D-07/UI-02 — zeigt alle vorhandenen Tender-Felder + Links zur Quelle. + * `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 die + * Quell-Links führen zum Original (T-11-09: `rel="noopener noreferrer"` + * + `target="_blank"` gegen Tampering über den externen Link). + * + * 13-06 (SCHEMA-03/D-03): ein cross-source-deduplizierter Tender kann + * mehrere `TenderSource`-Zeilen haben (`tender.sources[]`) — die Liste + * zeigt EINEN Link je Quellportal statt nur des einzelnen `sourceUrl`. + * Fallback auf den einzelnen `sourceUrl`-Block, falls `sources` fehlt + * oder leer ist (Alt-Tender / API-Antworten ohne include). * * Hardcoded German strings — i18n rollout is Phase 14 (Research Open * Question 3, RESOLVED), same convention as ResultsList/FilterPanel. @@ -63,6 +69,23 @@ function fieldLabelCls() { return 'text-xs font-semibold uppercase tracking-wider text-muted-foreground'; } +/** + * Hardcoded German display labels for known sourcePortal slugs (13-06, + * SCHEMA-03/D-03). i18n rollout is Phase 14 — same convention as the rest + * of this component. Unknown slugs (future portals) fall back to the raw + * slug so the list never silently drops a source. + */ +function portalLabel(sourcePortal: string): string { + const labels: Record = { + 'doe-opendata': 'DÖE', + tender24: 'tender24', + 'lhs-vpbw': 'lhs-vpbw', + 'vergabe.landbw': 'vergabe.landbw', + 'cosinex-dtvp': 'DTVP', + }; + return labels[sourcePortal] ?? sourcePortal; +} + export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { const [tender, setTender] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -193,7 +216,30 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { folgende Link führt direkt zum Original auf dem Quellportal.

- {tender.sourceUrl ? ( + {tender.sources && tender.sources.length > 0 ? ( + + ) : tender.sourceUrl ? (