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();
|
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 () => {
|
it('always shows the no-local-mirroring notice for Vergabeunterlagen', async () => {
|
||||||
mockGetTender.mockResolvedValue({
|
mockGetTender.mockResolvedValue({
|
||||||
id: 't3',
|
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
|
* nur die `tenderId` aus dem URL-Param + `onClose` durch, der eigentliche
|
||||||
* `getTender`-Aufruf und dessen Ladezustand leben hier.
|
* `getTender`-Aufruf und dessen Ladezustand leben hier.
|
||||||
*
|
*
|
||||||
* D-07/UI-02 — zeigt alle vorhandenen Tender-Felder + einen Link zur
|
* D-07/UI-02 — zeigt alle vorhandenen Tender-Felder + Links zur Quelle.
|
||||||
* Quelle (`sourceUrl`). `rawPayload` ist zu 100% NULL in der Live-DB
|
* `rawPayload` ist zu 100% NULL in der Live-DB (Research OQ1) — es
|
||||||
* (Research OQ1) — es existieren KEINE gespeicherten Dokument-URLs. Daher:
|
* existieren KEINE gespeicherten Dokument-URLs. Daher: KEIN Live-
|
||||||
* KEIN Live-Dokument-Fetch, KEIN lokales Spiegeln der Vergabeunterlagen —
|
* Dokument-Fetch, KEIN lokales Spiegeln der Vergabeunterlagen — nur die
|
||||||
* nur der Quell-Link führt zum Original (T-11-09: `rel="noopener
|
* Quell-Links führen zum Original (T-11-09: `rel="noopener noreferrer"`
|
||||||
* noreferrer"` + `target="_blank"` gegen Tampering über den externen Link).
|
* + `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
|
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
|
||||||
* Question 3, RESOLVED), same convention as ResultsList/FilterPanel.
|
* 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';
|
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) {
|
export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||||
const [tender, setTender] = useState<Tender | null>(null);
|
const [tender, setTender] = useState<Tender | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -193,7 +216,30 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
|||||||
folgende Link führt direkt zum Original auf dem
|
folgende Link führt direkt zum Original auf dem
|
||||||
Quellportal.
|
Quellportal.
|
||||||
</p>
|
</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
|
<a
|
||||||
href={tender.sourceUrl}
|
href={tender.sourceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ export interface Tender {
|
|||||||
status: string;
|
status: string;
|
||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
publishedAt: string;
|
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. */
|
/** Response shape of GET /modules/tender-radar. */
|
||||||
|
|||||||
Reference in New Issue
Block a user