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 ? (