feat(260723-lvg): add "Jetzt abrufen" manual poll button to tender radar

PollNowButton sits next to the settings gear in the tender-radar header,
mirrors the DKV spinner/disabled UX, and bumps a refreshKey on success to
refetch ResultsList without touching any filter. Failure surfaces an
i18n error (de/en parity). pollNow() client hits POST
/modules/tender-radar/poll-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:54:43 +02:00
parent 7502f97e85
commit bdaf154f66
7 changed files with 268 additions and 24 deletions
@@ -0,0 +1,102 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* PollNowButton.test — RED-first (TDD) proof for Quick 260723-lvg Task 2.
* Mirrors the ResultsList.test.tsx mock-the-api-client-module convention:
* mock @/lib/tender-radar-api's pollNow, mock next-intl with a flat
* key -> copy lookup for the `page.*` keys this component uses.
*/
const mockPollNow = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({
pollNow: (...args: unknown[]) => mockPollNow(...args),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
'page.pollNow': 'Jetzt abrufen',
'page.pollNowTitle': 'Fällige Quellen jetzt abrufen',
'page.polling': 'Wird abgerufen…',
'page.pollError': 'Der Abruf konnte nicht ausgelöst werden. Möglicherweise fehlen Ihnen die nötigen Rechte.',
'page.pollErrorDismiss': 'Schließen',
};
return translations[key] ?? key;
},
}));
afterEach(() => {
cleanup();
mockPollNow.mockReset();
vi.restoreAllMocks();
});
describe('PollNowButton', () => {
it('renders the "Jetzt abrufen" label', async () => {
const { PollNowButton } = await import('./PollNowButton');
render(<PollNowButton />);
expect(screen.getByRole('button', { name: 'Jetzt abrufen' })).toBeInTheDocument();
});
it('clicking the button calls pollNow once and calls onPolled on success', async () => {
mockPollNow.mockResolvedValue({ ok: true });
const onPolled = vi.fn();
const { PollNowButton } = await import('./PollNowButton');
render(<PollNowButton onPolled={onPolled} />);
fireEvent.click(screen.getByRole('button', { name: 'Jetzt abrufen' }));
await waitFor(() => {
expect(onPolled).toHaveBeenCalledTimes(1);
});
expect(mockPollNow).toHaveBeenCalledTimes(1);
});
it('shows the spinner/polling copy and disables the button while the poll is pending', async () => {
let resolvePoll: (value: { ok: boolean }) => void = () => {};
mockPollNow.mockReturnValue(
new Promise((resolve) => {
resolvePoll = resolve;
}),
);
const onPolled = vi.fn();
const { PollNowButton } = await import('./PollNowButton');
render(<PollNowButton onPolled={onPolled} />);
fireEvent.click(screen.getByRole('button', { name: 'Jetzt abrufen' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Wird abgerufen…/ })).toBeDisabled();
});
resolvePoll({ ok: true });
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Jetzt abrufen' })).not.toBeDisabled();
});
});
it('on rejection shows the pollError copy and never calls onPolled', async () => {
mockPollNow.mockRejectedValue(new Error('403'));
const onPolled = vi.fn();
const { PollNowButton } = await import('./PollNowButton');
render(<PollNowButton onPolled={onPolled} />);
fireEvent.click(screen.getByRole('button', { name: 'Jetzt abrufen' }));
await waitFor(() => {
expect(
screen.getByText(
'Der Abruf konnte nicht ausgelöst werden. Möglicherweise fehlen Ihnen die nötigen Rechte.',
),
).toBeInTheDocument();
});
expect(onPolled).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { pollNow } from '@/lib/tender-radar-api';
/**
* Inline spinner SVG — copied from the DKV "Jetzt prüfen" pattern
* (dkv-fleet/page.tsx), 20×20, animate-spin, stroke="currentColor".
*/
function SpinnerIcon() {
return (
<svg
className="mr-2 h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
);
}
interface PollNowButtonProps {
/** Called after a successful poll — drives the parent's refreshKey bump. */
onPolled?: () => void;
}
/**
* PollNowButton — "Jetzt abrufen" manual poll trigger for the
* Ausschreibungs-Radar header (Quick 260723-lvg). Self-contained so it can
* be tested in isolation (same convention as CoverageBanner/ResultsList/
* SavedSearchBar). Mirrors the DKV "Jetzt prüfen" spinner/disabled UX.
*
* Semantic honesty (see PLAN.md): the button triggers fällige Quellen jetzt
* abrufen, NOT a forced re-download — `pollDueSources()` honors the
* existing day-cursor server-side.
*/
export function PollNowButton({ onPolled }: PollNowButtonProps) {
const t = useTranslations('tenderRadar');
const [isPolling, setIsPolling] = useState(false);
const [pollError, setPollError] = useState<string | null>(null);
const handlePoll = async () => {
setIsPolling(true);
setPollError(null);
try {
await pollNow();
onPolled?.();
} catch {
setPollError(t('page.pollError'));
} finally {
setIsPolling(false);
}
};
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={handlePoll}
disabled={isPolling}
title={t('page.pollNowTitle')}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
isPolling ? ' opacity-80 cursor-not-allowed' : ''
}`}
>
{isPolling ? (
<>
<SpinnerIcon />
{t('page.polling')}
</>
) : (
t('page.pollNow')
)}
</button>
{pollError && (
<div className="flex items-center gap-2 text-xs text-destructive">
<span>{pollError}</span>
<button
type="button"
onClick={() => setPollError(null)}
className="font-medium hover:opacity-70"
aria-label={t('page.pollErrorDismiss')}
>
×
</button>
</div>
)}
</div>
);
}
@@ -23,6 +23,12 @@ import {
* *
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace. * tenderRadar i18n namespace.
*
* Quick 260723-lvg: accepts an optional `refreshKey` prop — incrementing it
* (driven by PollNowButton.onPolled in page.tsx) re-runs `load()` and
* refetches the list without any URL/filter change, same
* parent-increments-refreshKey pattern DKV uses for InvoiceHistoryTable
* (STATE decision 07-05).
*/ */
const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; labelKey: string }> = [ const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; labelKey: string }> = [
@@ -62,7 +68,7 @@ function formatValue(value: string | null, noValueLabel: string): string {
}); });
} }
export function ResultsList() { export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
const t = useTranslations('tenderRadar'); const t = useTranslations('tenderRadar');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -121,7 +127,7 @@ export function ResultsList() {
setIsLoading(false); setIsLoading(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [paramsKey]); }, [paramsKey, refreshKey]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -1,11 +1,12 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { CoverageBanner } from './components/CoverageBanner'; import { CoverageBanner } from './components/CoverageBanner';
import { FilterPanel } from './components/FilterPanel'; import { FilterPanel } from './components/FilterPanel';
import { PollNowButton } from './components/PollNowButton';
import { ResultsList } from './components/ResultsList'; import { ResultsList } from './components/ResultsList';
import { SavedSearchBar } from './components/SavedSearchBar'; import { SavedSearchBar } from './components/SavedSearchBar';
import { TenderDetail } from './components/TenderDetail'; import { TenderDetail } from './components/TenderDetail';
@@ -38,12 +39,18 @@ import { TenderDetail } from './components/TenderDetail';
* *
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace — no more hardcoded German literals. * tenderRadar i18n namespace — no more hardcoded German literals.
*
* Quick 260723-lvg: PollNowButton sits next to the settings gear and
* bumps `refreshKey` on a successful poll, driving a ResultsList refetch —
* same parent-increments-refreshKey pattern DKV uses for
* InvoiceHistoryTable (STATE decision 07-05).
*/ */
function TenderRadarContent() { function TenderRadarContent() {
const t = useTranslations('tenderRadar'); const t = useTranslations('tenderRadar');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tenderId = searchParams.get('tender'); const tenderId = searchParams.get('tender');
const [refreshKey, setRefreshKey] = useState(0);
const closeDetail = () => { const closeDetail = () => {
const next = new URLSearchParams(searchParams.toString()); const next = new URLSearchParams(searchParams.toString());
@@ -58,6 +65,8 @@ function TenderRadarContent() {
{t('page.title')} {t('page.title')}
</h1> </h1>
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
<div className="flex items-center gap-2">
{/* {/*
* Einstellungen-Link. Absolute literal href (NOT the dynamic * Einstellungen-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): the tender-radar settings page * [category]/[moduleSlug] basePath): the tender-radar settings page
@@ -76,12 +85,15 @@ function TenderRadarContent() {
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg> </svg>
</Link> </Link>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</div>
</div> </div>
<CoverageBanner /> <CoverageBanner />
<SavedSearchBar /> <SavedSearchBar />
<FilterPanel /> <FilterPanel />
<ResultsList /> <ResultsList refreshKey={refreshKey} />
{tenderId && <TenderDetail tenderId={tenderId} onClose={closeDetail} />} {tenderId && <TenderDetail tenderId={tenderId} onClose={closeDetail} />}
</div> </div>
+17
View File
@@ -150,6 +150,23 @@ export async function listTenders(
return res.json(); return res.json();
} }
/**
* Trigger an immediate manual poll of all DUE tender sources (Quick
* 260723-lvg — "Jetzt abrufen"). Mirrors the DKV `checkNow` client shape.
* This is honest "fällige Quellen jetzt abrufen": 'day'-granularity
* sources still honor their nextDayToFetch cursor server-side — this is
* NOT a forced re-download.
* POST /modules/tender-radar/poll-now
*/
export async function pollNow(): Promise<{ ok: boolean }> {
const res = await fetch(`${API_URL}/modules/tender-radar/poll-now`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to trigger tender poll');
return res.json();
}
/** /**
* Fetch the active-tender distribution by sourcePortal (D-12, UI-05) — * Fetch the active-tender distribution by sourcePortal (D-12, UI-05) —
* feeds CoverageBanner so a thin/single-source result list is not * feeds CoverageBanner so a thin/single-source result list is not
+6 -1
View File
@@ -576,7 +576,12 @@
"page": { "page": {
"title": "Ausschreibungs-Radar", "title": "Ausschreibungs-Radar",
"loading": "Lädt…", "loading": "Lädt…",
"settingsTitle": "Einstellungen" "settingsTitle": "Einstellungen",
"pollNow": "Jetzt abrufen",
"pollNowTitle": "Fällige Quellen jetzt abrufen",
"polling": "Wird abgerufen…",
"pollError": "Der Abruf konnte nicht ausgelöst werden. Möglicherweise fehlen Ihnen die nötigen Rechte.",
"pollErrorDismiss": "Schließen"
}, },
"results": { "results": {
"colTitle": "Titel", "colTitle": "Titel",
+6 -1
View File
@@ -576,7 +576,12 @@
"page": { "page": {
"title": "Tender Radar", "title": "Tender Radar",
"loading": "Loading…", "loading": "Loading…",
"settingsTitle": "Settings" "settingsTitle": "Settings",
"pollNow": "Fetch now",
"pollNowTitle": "Fetch due sources now",
"polling": "Fetching…",
"pollError": "The fetch could not be triggered. You may not have the required permissions.",
"pollErrorDismiss": "Dismiss"
}, },
"results": { "results": {
"colTitle": "Title", "colTitle": "Title",