diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx
new file mode 100644
index 0000000..fca6e30
--- /dev/null
+++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx
@@ -0,0 +1,97 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05).
+ * Mirrors the SourceConfigForm.test.tsx pattern: mock the api client
+ * module, render, assert via waitFor. next/navigation is mocked because
+ * ResultsList reads/writes URL searchParams for filter + sort state
+ * (Research: URL-Param-getriebener Client).
+ */
+
+const mockListTenders = vi.fn();
+
+vi.mock('@/lib/tender-radar-api', () => ({
+ listTenders: (...args: unknown[]) => mockListTenders(...args),
+}));
+
+const mockReplace = vi.fn();
+let mockSearchParams = new URLSearchParams();
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ replace: mockReplace }),
+ useSearchParams: () => mockSearchParams,
+}));
+
+afterEach(() => {
+ cleanup();
+ mockListTenders.mockReset();
+ mockReplace.mockReset();
+ mockSearchParams = new URLSearchParams();
+ vi.restoreAllMocks();
+});
+
+describe('ResultsList', () => {
+ it('fetches on mount and renders title, buyerName, deadline and value from listTenders items', async () => {
+ mockListTenders.mockResolvedValue({
+ items: [
+ {
+ id: 't1',
+ title: 'Sanierung Rathaus',
+ buyerName: 'Stadt Musterhausen',
+ deadlineAt: '2026-09-01T00:00:00.000Z',
+ estimatedValue: '150000.00',
+ publishedAt: '2026-07-01T00:00:00.000Z',
+ },
+ ],
+ total: 1,
+ page: 1,
+ limit: 20,
+ });
+
+ const { ResultsList } = await import('./ResultsList');
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Sanierung Rathaus')).toBeInTheDocument();
+ });
+ expect(screen.getByText('Stadt Musterhausen')).toBeInTheDocument();
+ expect(mockListTenders).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows "keine Wertangabe" for a row with estimatedValue null (D-05 graceful handling)', async () => {
+ mockListTenders.mockResolvedValue({
+ items: [
+ {
+ id: 't2',
+ title: 'Straßenbau Projekt X',
+ buyerName: 'Landkreis Beispiel',
+ deadlineAt: null,
+ estimatedValue: null,
+ publishedAt: '2026-07-01T00:00:00.000Z',
+ },
+ ],
+ total: 1,
+ page: 1,
+ limit: 20,
+ });
+
+ const { ResultsList } = await import('./ResultsList');
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('keine Wertangabe')).toBeInTheDocument();
+ });
+ });
+
+ it('renders an empty-state message when listTenders returns no items', async () => {
+ mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
+
+ const { ResultsList } = await import('./ResultsList');
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Keine Ausschreibungen gefunden/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts
index 03a5d92..e4b8815 100644
--- a/apps/web/src/lib/tender-radar-api.ts
+++ b/apps/web/src/lib/tender-radar-api.ts
@@ -33,6 +33,44 @@ export interface SaveSourceConfigPayload {
isActive: boolean;
}
+/**
+ * A single tender row from the global `Tender` catalog (Plan 11-01).
+ * `estimatedValue` arrives as a string (Prisma Decimal serializes via
+ * `toJSON()` -> `toString()`), NOT a number — never `Number()`-coerce it
+ * without a null check (D-05: 91.6% of rows are null, "keine Wertangabe").
+ * Date fields arrive as ISO strings (JSON has no native Date type).
+ */
+export interface Tender {
+ id: string;
+ sourcePortal: string;
+ title: string;
+ buyerName: string | null;
+ cpvCodes: string[];
+ region: string | null;
+ plz: string | null;
+ bundesland: string | null;
+ deadlineAt: string | null;
+ estimatedValue: string | null;
+ procedureType: string | null;
+ status: string;
+ sourceUrl: string | null;
+ publishedAt: string;
+}
+
+/** Response shape of GET /modules/tender-radar. */
+export interface ListTendersResponse {
+ items: Tender[];
+ total: number;
+ page: number;
+ limit: number;
+}
+
+/** Response shape of GET /modules/tender-radar/coverage (D-12, UI-05). */
+export interface CoverageResponse {
+ total: number;
+ sources: Array<{ sourcePortal: string; count: number }>;
+}
+
// --- API functions ---
/**
@@ -65,3 +103,36 @@ export async function saveSourceConfig(
if (!res.ok) throw new Error('Failed to save tender-radar source config');
return res.json();
}
+
+/**
+ * List the global tender catalog with the given filter/sort/pagination
+ * params (Plan 11-01, FILTER-01/04/05, UI-01). `params` mirrors the
+ * TenderQueryDto query-string contract exactly — the URL is the single
+ * source of truth for filter state (Research: URL-Param-getriebener Client).
+ * GET /modules/tender-radar?
+ */
+export async function listTenders(
+ params: URLSearchParams,
+): Promise {
+ const query = params.toString();
+ const res = await fetch(
+ `${API_URL}/modules/tender-radar${query ? `?${query}` : ''}`,
+ { credentials: 'include' },
+ );
+ if (!res.ok) throw new Error('Failed to fetch tenders');
+ return res.json();
+}
+
+/**
+ * Fetch the active-tender distribution by sourcePortal (D-12, UI-05) —
+ * feeds CoverageBanner so a thin/single-source result list is not
+ * misread as a defect.
+ * GET /modules/tender-radar/coverage
+ */
+export async function fetchCoverage(): Promise {
+ const res = await fetch(`${API_URL}/modules/tender-radar/coverage`, {
+ credentials: 'include',
+ });
+ if (!res.ok) throw new Error('Failed to fetch tender-radar coverage');
+ return res.json();
+}