From 1eb9e4f567a875817b15a6586b155c7998e11c4f Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 16:37:30 +0200 Subject: [PATCH] feat(11-05): read/favorite triage toggles + Merklisten-Filter in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds fetchTriage()/setTriage() to tender-radar-api.ts (plain fetch, consistent with the existing client). ResultsList batch-fetches the current user's triage state for the visible ids and merges it into a local per-tenderId map; a failed triage fetch never blocks rendering the list itself. Each row gets a Gelesen/Ungelesen and a Favorit toggle (optimistic update with revert-on-failure, event.stopPropagation() so the row's own click-to-open-detail doesn't fire); read rows render dimmed. FilterPanel gains a "Nur Favoriten/Merkliste" checkbox writing favOnly into the URL, which ResultsList already forwards generically to the backend. ResultsList.test.tsx extended (Rule 3 — required to keep the component test green with the new triage batch call) with coverage for batch-merge, both toggles, optimistic revert, and graceful degradation when the triage fetch fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tender-radar/components/FilterPanel.tsx | 17 ++ .../components/ResultsList.test.tsx | 129 ++++++++++++- .../tender-radar/components/ResultsList.tsx | 170 ++++++++++++++++-- apps/web/src/lib/tender-radar-api.ts | 53 ++++++ 4 files changed, 344 insertions(+), 25 deletions(-) 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 + +
+ setParam('favOnly', e.target.checked ? 'true' : null)} + className="h-4 w-4" + /> + +
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)} + +
+ + +
+ + + ); + }) )} 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(); +}