feat(13-06): TenderDetail renders all cross-source links

Tender.sources[] added to the API client type (sourcePortal, sourceUrl,
sourceNoticeId). TenderDetail now renders one link per TenderSource
with a German portal label (DÖE/tender24/DTVP/...), falling back to
the existing single sourceUrl block when sources is missing or empty
(older responses, single-source tenders).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:56:06 +02:00
parent 166194fa04
commit bf61316500
3 changed files with 146 additions and 7 deletions
@@ -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(<TenderDetail tenderId="t4" onClose={() => {}} />);
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(<TenderDetail tenderId="t5" onClose={() => {}} />);
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',
@@ -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<string, string> = {
'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<Tender | null>(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.
</p>
{tender.sourceUrl ? (
{tender.sources && tender.sources.length > 0 ? (
<ul className="mt-2 space-y-1">
{tender.sources.map((source) => (
<li key={`${source.sourcePortal}:${source.sourceNoticeId}`}>
{source.sourceUrl ? (
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
>
{portalLabel(source.sourcePortal)}: Zur Ausschreibung
auf dem Quellportal →
</a>
) : (
<span className="text-sm text-muted-foreground">
{portalLabel(source.sourcePortal)}: Kein Quell-Link
verfügbar.
</span>
)}
</li>
))}
</ul>
) : tender.sourceUrl ? (
<a
href={tender.sourceUrl}
target="_blank"
+12
View File
@@ -55,6 +55,18 @@ export interface Tender {
status: string;
sourceUrl: string | null;
publishedAt: string;
/**
* Cross-source-dedup links (13-06, SCHEMA-03, D-03): all TenderSource
* rows for this tender, one per portal it was found on. Optional —
* only `getTender` (detail) populates it; `listTenders` rows and
* older responses may omit it entirely, so callers must fall back to
* the single `sourceUrl` field when it's missing/empty.
*/
sources?: {
sourcePortal: string;
sourceUrl: string | null;
sourceNoticeId: string;
}[];
}
/** Response shape of GET /modules/tender-radar. */