diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx
index 68507d2..32f1731 100644
--- a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx
+++ b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx
@@ -128,6 +128,10 @@ export function FilterPanel() {
// includeNullValue defaults to true (D-05 Pflichtkriterium) — only an
// explicit "false" excludes rows without an estimatedValue.
const includeNullValue = searchParams.get('includeNullValue') !== 'false';
+ // Merklisten-Filter (UI-04, D-10). Default off — only an explicit "true"
+ // restricts the list to this user's favorited tenders (backend derives
+ // the favorite tenderIds from the auth context, never from this param).
+ const favOnly = searchParams.get('favOnly') === 'true';
const setParam = (key: string, value: string | null) => {
const next = new URLSearchParams(searchParams.toString());
@@ -220,6 +224,19 @@ export function FilterPanel() {
Nur noch offene
+
+
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
index fca6e30..30d176a 100644
--- 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
@@ -1,18 +1,23 @@
-import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { cleanup, fireEvent, 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).
+ * ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05)
+ * plus Plan 11-05 (UI-03/04: gelesen/ungelesen + Favorit toggles, batch
+ * triage merge). 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();
+const mockFetchTriage = vi.fn();
+const mockSetTriage = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({
listTenders: (...args: unknown[]) => mockListTenders(...args),
+ fetchTriage: (...args: unknown[]) => mockFetchTriage(...args),
+ setTriage: (...args: unknown[]) => mockSetTriage(...args),
}));
const mockReplace = vi.fn();
@@ -26,6 +31,8 @@ vi.mock('next/navigation', () => ({
afterEach(() => {
cleanup();
mockListTenders.mockReset();
+ mockFetchTriage.mockReset();
+ mockSetTriage.mockReset();
mockReplace.mockReset();
mockSearchParams = new URLSearchParams();
vi.restoreAllMocks();
@@ -48,6 +55,7 @@ describe('ResultsList', () => {
page: 1,
limit: 20,
});
+ mockFetchTriage.mockResolvedValue([]);
const { ResultsList } = await import('./ResultsList');
render(
);
@@ -75,6 +83,7 @@ describe('ResultsList', () => {
page: 1,
limit: 20,
});
+ mockFetchTriage.mockResolvedValue([]);
const { ResultsList } = await import('./ResultsList');
render(
);
@@ -86,6 +95,7 @@ describe('ResultsList', () => {
it('renders an empty-state message when listTenders returns no items', async () => {
mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
+ mockFetchTriage.mockResolvedValue([]);
const { ResultsList } = await import('./ResultsList');
render(
);
@@ -95,3 +105,110 @@ describe('ResultsList', () => {
});
});
});
+
+describe('ResultsList — triage (UI-03/04, Plan 11-05: batch-merge + read/favorite toggles)', () => {
+ const oneItem = {
+ 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,
+ };
+
+ it('batch-fetches triage for the visible ids and merges the returned isRead/isFavorite state in', async () => {
+ mockListTenders.mockResolvedValue(oneItem);
+ mockFetchTriage.mockResolvedValue([
+ { tenderId: 't1', isRead: true, isFavorite: true },
+ ]);
+
+ const { ResultsList } = await import('./ResultsList');
+ render(
);
+
+ await waitFor(() => {
+ expect(mockFetchTriage).toHaveBeenCalledWith(['t1']);
+ });
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Als ungelesen markieren' })).toBeInTheDocument();
+ });
+ expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
+ });
+
+ it('clicking the read toggle calls setTriage(tenderId, isRead: true) and flips the button label optimistically', async () => {
+ mockListTenders.mockResolvedValue(oneItem);
+ mockFetchTriage.mockResolvedValue([]);
+ mockSetTriage.mockResolvedValue({ tenderId: 't1', isRead: true, isFavorite: false });
+
+ const { ResultsList } = await import('./ResultsList');
+ render(
);
+
+ const readButton = await screen.findByRole('button', {
+ name: 'Als gelesen markieren',
+ });
+ fireEvent.click(readButton);
+
+ expect(mockSetTriage).toHaveBeenCalledWith({ tenderId: 't1', isRead: true });
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: 'Als ungelesen markieren' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('clicking the favorite toggle calls setTriage(tenderId, isFavorite: true) and does not trigger the row click (openDetail)', async () => {
+ mockListTenders.mockResolvedValue(oneItem);
+ mockFetchTriage.mockResolvedValue([]);
+ mockSetTriage.mockResolvedValue({ tenderId: 't1', isRead: false, isFavorite: true });
+
+ const { ResultsList } = await import('./ResultsList');
+ render(
);
+
+ const favButton = await screen.findByRole('button', { name: 'Favorit setzen' });
+ fireEvent.click(favButton);
+
+ expect(mockSetTriage).toHaveBeenCalledWith({ tenderId: 't1', isFavorite: true });
+ expect(mockReplace).not.toHaveBeenCalled();
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
+ });
+ });
+
+ it('reverts the optimistic update when setTriage rejects', async () => {
+ mockListTenders.mockResolvedValue(oneItem);
+ mockFetchTriage.mockResolvedValue([]);
+ mockSetTriage.mockRejectedValue(new Error('network error'));
+
+ const { ResultsList } = await import('./ResultsList');
+ render(
);
+
+ const readButton = await screen.findByRole('button', {
+ name: 'Als gelesen markieren',
+ });
+ fireEvent.click(readButton);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: 'Als gelesen markieren' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('a failed batch triage fetch does not block rendering the tender list', async () => {
+ mockListTenders.mockResolvedValue(oneItem);
+ mockFetchTriage.mockRejectedValue(new Error('network error'));
+
+ const { ResultsList } = await import('./ResultsList');
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Sanierung Rathaus')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx
index 256c664..10d8445 100644
--- a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx
+++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx
@@ -2,7 +2,12 @@
import { useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
-import { listTenders, type Tender } from '@/lib/tender-radar-api';
+import {
+ fetchTriage,
+ listTenders,
+ setTriage,
+ type Tender,
+} from '@/lib/tender-radar-api';
/**
* ResultsList — the Trefferliste replacing the Phase 10 module placeholder
@@ -24,6 +29,12 @@ const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; label: stri
const DEFAULT_LIMIT = 20;
+/** Per-tenderId triage state merged in from the batch GET /triage fetch. */
+interface TriageState {
+ isRead: boolean;
+ isFavorite: boolean;
+}
+
function formatDate(value: string | null): string {
if (!value) return '–';
const d = new Date(value);
@@ -57,6 +68,12 @@ export function ResultsList() {
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState
(null);
+ // UI-03/04: per-tenderId triage overlay (gelesen/ungelesen, Favorit),
+ // merged in from a batch GET /triage fetch of the visible ids. Keyed by
+ // tenderId, not persisted beyond this component's lifetime — always
+ // re-fetched per page/filter change (D-11: per-user, never trusted from
+ // any other source).
+ const [triage, setTriageMap] = useState>({});
const load = useCallback(async () => {
setIsLoading(true);
@@ -68,6 +85,29 @@ export function ResultsList() {
setItems(data.items);
setTotal(data.total);
setPage(data.page);
+
+ // Batch-merge this user's triage state for the visible ids (UI-03/04,
+ // key_link: "GET /triage batch → merge in ResultsList"). Triage is a
+ // supplementary per-user overlay — a failed batch-load must not block
+ // rendering the tender list itself.
+ const ids = data.items.map((t) => t.id);
+ try {
+ const rows = await fetchTriage(ids);
+ if (Array.isArray(rows)) {
+ setTriageMap((prev) => {
+ const next = { ...prev };
+ for (const row of rows) {
+ next[row.tenderId] = {
+ isRead: row.isRead,
+ isFavorite: row.isFavorite,
+ };
+ }
+ return next;
+ });
+ }
+ } catch {
+ // Swallow — the list itself is not blocked by a triage load failure.
+ }
} catch (err) {
setError(
err instanceof Error
@@ -107,6 +147,45 @@ export function ResultsList() {
router.replace(`?${next.toString()}`);
};
+ /**
+ * Gelesen/Ungelesen-Toggle (D-09, UI-03). Optimistic update — reverts on
+ * a failed PUT so the UI never claims a state the backend didn't accept.
+ * `stopPropagation` prevents the row's own onClick (openDetail) firing.
+ */
+ const toggleRead = async (e: React.MouseEvent, tender: Tender) => {
+ e.stopPropagation();
+ const previous = triage[tender.id] ?? { isRead: false, isFavorite: false };
+ const nextRead = !previous.isRead;
+ setTriageMap((prev) => ({
+ ...prev,
+ [tender.id]: { ...previous, isRead: nextRead },
+ }));
+ try {
+ await setTriage({ tenderId: tender.id, isRead: nextRead });
+ } catch {
+ setTriageMap((prev) => ({ ...prev, [tender.id]: previous }));
+ }
+ };
+
+ /**
+ * Favorit-Toggle (D-10, UI-04). Same optimistic-with-revert pattern as
+ * toggleRead.
+ */
+ const toggleFavorite = async (e: React.MouseEvent, tender: Tender) => {
+ e.stopPropagation();
+ const previous = triage[tender.id] ?? { isRead: false, isFavorite: false };
+ const nextFavorite = !previous.isFavorite;
+ setTriageMap((prev) => ({
+ ...prev,
+ [tender.id]: { ...previous, isFavorite: nextFavorite },
+ }));
+ try {
+ await setTriage({ tenderId: tender.id, isFavorite: nextFavorite });
+ } catch {
+ setTriageMap((prev) => ({ ...prev, [tender.id]: previous }));
+ }
+ };
+
const limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT);
const totalPages = Math.max(1, Math.ceil(total / limit));
@@ -132,13 +211,14 @@ export function ResultsList() {
{currentSort === h.key ? ' ▾' : ''}
))}
+ Triage
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
- {Array.from({ length: 5 }).map((_, j) => (
+ {Array.from({ length: 6 }).map((_, j) => (
@@ -147,13 +227,13 @@ export function ResultsList() {
))
) : error ? (
-
+
{error}
) : items.length === 0 ? (
-
+
Keine Ausschreibungen gefunden.
@@ -164,21 +244,73 @@ export function ResultsList() {
) : (
- items.map((tender) => (
- openDetail(tender.id)}
- className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
- >
-
- {tender.title}
-
- {tender.buyerName ?? '–'}
- {formatDate(tender.deadlineAt)}
- {formatValue(tender.estimatedValue)}
- {formatDate(tender.publishedAt)}
-
- ))
+ items.map((tender) => {
+ const rowTriage = triage[tender.id] ?? {
+ isRead: false,
+ isFavorite: false,
+ };
+ return (
+ openDetail(tender.id)}
+ className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors ${
+ rowTriage.isRead ? 'opacity-60' : ''
+ }`}
+ >
+
+ {tender.title}
+
+ {tender.buyerName ?? '–'}
+ {formatDate(tender.deadlineAt)}
+ {formatValue(tender.estimatedValue)}
+ {formatDate(tender.publishedAt)}
+
+
+ toggleRead(e, tender)}
+ aria-pressed={rowTriage.isRead}
+ aria-label={
+ rowTriage.isRead
+ ? 'Als ungelesen markieren'
+ : 'Als gelesen markieren'
+ }
+ title={
+ rowTriage.isRead
+ ? 'Als ungelesen markieren'
+ : 'Als gelesen markieren'
+ }
+ className="rounded border border-border px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
+ >
+ {rowTriage.isRead ? 'Gelesen' : 'Ungelesen'}
+
+ toggleFavorite(e, tender)}
+ aria-pressed={rowTriage.isFavorite}
+ aria-label={
+ rowTriage.isFavorite
+ ? 'Favorit entfernen'
+ : 'Favorit setzen'
+ }
+ title={
+ rowTriage.isFavorite
+ ? 'Favorit entfernen'
+ : 'Favorit setzen'
+ }
+ className={`rounded border px-2 py-1 text-xs transition-colors hover:bg-muted ${
+ rowTriage.isFavorite
+ ? 'border-amber-400 text-amber-500'
+ : 'border-border text-foreground'
+ }`}
+ >
+ {rowTriage.isFavorite ? '★ Favorit' : '☆ Favorit'}
+
+
+
+
+ );
+ })
)}
diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts
index 9209f1f..04107d9 100644
--- a/apps/web/src/lib/tender-radar-api.ts
+++ b/apps/web/src/lib/tender-radar-api.ts
@@ -151,3 +151,56 @@ export async function getTender(id: string): Promise {
if (!res.ok) throw new Error('Failed to fetch tender detail');
return res.json();
}
+
+/**
+ * A single per-user triage row (Plan 11-05, UI-03/04). Per-user, NOT
+ * tenant-wide (D-11) — the backend derives userId from the auth cookie,
+ * this client never sends a userId.
+ */
+export interface TriageEntry {
+ tenderId: string;
+ isRead: boolean;
+ isFavorite: boolean;
+}
+
+/** Payload accepted by PUT /modules/tender-radar/triage. */
+export interface SetTriagePayload {
+ tenderId: string;
+ isRead?: boolean;
+ isFavorite?: boolean;
+}
+
+/**
+ * Batch-fetch this user's triage state (gelesen/ungelesen, Favorit) for a
+ * set of tenderIds (Plan 11-05, UI-03/04) — used by ResultsList to merge
+ * triage state into the visible page in one round-trip.
+ * GET /modules/tender-radar/triage?ids=
+ */
+export async function fetchTriage(ids: string[]): Promise {
+ if (!ids.length) return [];
+ const params = new URLSearchParams({ ids: ids.join(',') });
+ const res = await fetch(
+ `${API_URL}/modules/tender-radar/triage?${params}`,
+ { credentials: 'include' },
+ );
+ if (!res.ok) throw new Error('Failed to fetch tender triage');
+ return res.json();
+}
+
+/**
+ * Upsert this user's triage state for one tender (Plan 11-05, UI-03/04).
+ * Idempotent on the backend (@@unique([userId,tenderId])).
+ * PUT /modules/tender-radar/triage
+ */
+export async function setTriage(
+ payload: SetTriagePayload,
+): Promise {
+ const res = await fetch(`${API_URL}/modules/tender-radar/triage`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error('Failed to save tender triage');
+ return res.json();
+}