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:
@@ -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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user