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:
@@ -128,6 +128,10 @@ export function FilterPanel() {
|
|||||||
// includeNullValue defaults to true (D-05 Pflichtkriterium) — only an
|
// includeNullValue defaults to true (D-05 Pflichtkriterium) — only an
|
||||||
// explicit "false" excludes rows without an estimatedValue.
|
// explicit "false" excludes rows without an estimatedValue.
|
||||||
const includeNullValue = searchParams.get('includeNullValue') !== 'false';
|
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 setParam = (key: string, value: string | null) => {
|
||||||
const next = new URLSearchParams(searchParams.toString());
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
@@ -220,6 +224,19 @@ export function FilterPanel() {
|
|||||||
Nur noch offene
|
Nur noch offene
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pb-2">
|
||||||
|
<input
|
||||||
|
id="tr-filter-fav-only"
|
||||||
|
type="checkbox"
|
||||||
|
checked={favOnly}
|
||||||
|
onChange={(e) => setParam('favOnly', e.target.checked ? 'true' : null)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="tr-filter-fav-only" className="text-sm text-foreground">
|
||||||
|
Nur Favoriten/Merkliste
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
|||||||
@@ -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';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05).
|
* ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05)
|
||||||
* Mirrors the SourceConfigForm.test.tsx pattern: mock the api client
|
* plus Plan 11-05 (UI-03/04: gelesen/ungelesen + Favorit toggles, batch
|
||||||
* module, render, assert via waitFor. next/navigation is mocked because
|
* triage merge). Mirrors the SourceConfigForm.test.tsx pattern: mock the
|
||||||
* ResultsList reads/writes URL searchParams for filter + sort state
|
* api client module, render, assert via waitFor. next/navigation is mocked
|
||||||
* (Research: URL-Param-getriebener Client).
|
* because ResultsList reads/writes URL searchParams for filter + sort
|
||||||
|
* state (Research: URL-Param-getriebener Client).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const mockListTenders = vi.fn();
|
const mockListTenders = vi.fn();
|
||||||
|
const mockFetchTriage = vi.fn();
|
||||||
|
const mockSetTriage = vi.fn();
|
||||||
|
|
||||||
vi.mock('@/lib/tender-radar-api', () => ({
|
vi.mock('@/lib/tender-radar-api', () => ({
|
||||||
listTenders: (...args: unknown[]) => mockListTenders(...args),
|
listTenders: (...args: unknown[]) => mockListTenders(...args),
|
||||||
|
fetchTriage: (...args: unknown[]) => mockFetchTriage(...args),
|
||||||
|
setTriage: (...args: unknown[]) => mockSetTriage(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockReplace = vi.fn();
|
const mockReplace = vi.fn();
|
||||||
@@ -26,6 +31,8 @@ vi.mock('next/navigation', () => ({
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
mockListTenders.mockReset();
|
mockListTenders.mockReset();
|
||||||
|
mockFetchTriage.mockReset();
|
||||||
|
mockSetTriage.mockReset();
|
||||||
mockReplace.mockReset();
|
mockReplace.mockReset();
|
||||||
mockSearchParams = new URLSearchParams();
|
mockSearchParams = new URLSearchParams();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -48,6 +55,7 @@ describe('ResultsList', () => {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
mockFetchTriage.mockResolvedValue([]);
|
||||||
|
|
||||||
const { ResultsList } = await import('./ResultsList');
|
const { ResultsList } = await import('./ResultsList');
|
||||||
render(<ResultsList />);
|
render(<ResultsList />);
|
||||||
@@ -75,6 +83,7 @@ describe('ResultsList', () => {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
mockFetchTriage.mockResolvedValue([]);
|
||||||
|
|
||||||
const { ResultsList } = await import('./ResultsList');
|
const { ResultsList } = await import('./ResultsList');
|
||||||
render(<ResultsList />);
|
render(<ResultsList />);
|
||||||
@@ -86,6 +95,7 @@ describe('ResultsList', () => {
|
|||||||
|
|
||||||
it('renders an empty-state message when listTenders returns no items', async () => {
|
it('renders an empty-state message when listTenders returns no items', async () => {
|
||||||
mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
|
mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
|
||||||
|
mockFetchTriage.mockResolvedValue([]);
|
||||||
|
|
||||||
const { ResultsList } = await import('./ResultsList');
|
const { ResultsList } = await import('./ResultsList');
|
||||||
render(<ResultsList />);
|
render(<ResultsList />);
|
||||||
@@ -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(<ResultsList />);
|
||||||
|
|
||||||
|
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(<ResultsList />);
|
||||||
|
|
||||||
|
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(<ResultsList />);
|
||||||
|
|
||||||
|
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(<ResultsList />);
|
||||||
|
|
||||||
|
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(<ResultsList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Sanierung Rathaus')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
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
|
* 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;
|
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 {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) return '–';
|
if (!value) return '–';
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
@@ -57,6 +68,12 @@ export function ResultsList() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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<Record<string, TriageState>>({});
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -68,6 +85,29 @@ export function ResultsList() {
|
|||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
setPage(data.page);
|
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) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
@@ -107,6 +147,45 @@ export function ResultsList() {
|
|||||||
router.replace(`?${next.toString()}`);
|
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 limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT);
|
||||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
@@ -132,13 +211,14 @@ export function ResultsList() {
|
|||||||
{currentSort === h.key ? ' ▾' : ''}
|
{currentSort === h.key ? ' ▾' : ''}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
<th className={thStaticCls}>Triage</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i} className="border-b border-border last:border-0">
|
<tr key={i} className="border-b border-border last:border-0">
|
||||||
{Array.from({ length: 5 }).map((_, j) => (
|
{Array.from({ length: 6 }).map((_, j) => (
|
||||||
<td key={j} className="px-4 py-3">
|
<td key={j} className="px-4 py-3">
|
||||||
<div className="h-4 rounded bg-muted animate-pulse" />
|
<div className="h-4 rounded bg-muted animate-pulse" />
|
||||||
</td>
|
</td>
|
||||||
@@ -147,13 +227,13 @@ export function ResultsList() {
|
|||||||
))
|
))
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-10 text-center text-sm text-destructive">
|
<td colSpan={6} className="px-4 py-10 text-center text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-10 text-center">
|
<td colSpan={6} className="px-4 py-10 text-center">
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
Keine Ausschreibungen gefunden.
|
Keine Ausschreibungen gefunden.
|
||||||
</p>
|
</p>
|
||||||
@@ -164,21 +244,73 @@ export function ResultsList() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
items.map((tender) => (
|
items.map((tender) => {
|
||||||
<tr
|
const rowTriage = triage[tender.id] ?? {
|
||||||
key={tender.id}
|
isRead: false,
|
||||||
onClick={() => openDetail(tender.id)}
|
isFavorite: false,
|
||||||
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
|
};
|
||||||
>
|
return (
|
||||||
<td className="px-4 py-3 font-medium text-foreground">
|
<tr
|
||||||
{tender.title}
|
key={tender.id}
|
||||||
</td>
|
onClick={() => openDetail(tender.id)}
|
||||||
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
|
className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted/50 transition-colors ${
|
||||||
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
|
rowTriage.isRead ? 'opacity-60' : ''
|
||||||
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
|
}`}
|
||||||
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
|
>
|
||||||
</tr>
|
<td className="px-4 py-3 font-medium text-foreground">
|
||||||
))
|
{tender.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
|
||||||
|
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
|
||||||
|
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
|
||||||
|
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => 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'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => 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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -151,3 +151,56 @@ export async function getTender(id: string): Promise<Tender> {
|
|||||||
if (!res.ok) throw new Error('Failed to fetch tender detail');
|
if (!res.ok) throw new Error('Failed to fetch tender detail');
|
||||||
return res.json();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user