feat(11-05): read/favorite triage toggles + Merklisten-Filter in the UI

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:37:30 +02:00
parent 5f97eca804
commit 1eb9e4f567
4 changed files with 344 additions and 25 deletions
+53
View File
@@ -151,3 +151,56 @@ export async function getTender(id: string): Promise<Tender> {
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=<csv>
*/
export async function fetchTriage(ids: string[]): Promise<TriageEntry[]> {
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<TriageEntry> {
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();
}