diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.test.tsx new file mode 100644 index 0000000..82bb742 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.test.tsx @@ -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 = { + '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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.tsx new file mode 100644 index 0000000..25923ce --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.tsx @@ -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 ( + + ); +} + +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(null); + + const handlePoll = async () => { + setIsPolling(true); + setPollError(null); + try { + await pollNow(); + onPolled?.(); + } catch { + setPollError(t('page.pollError')); + } finally { + setIsPolling(false); + } + }; + + return ( +
+ + + {pollError && ( +
+ {pollError} + +
+ )} +
+ ); +} 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 591ff65..5b0f84c 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 @@ -23,6 +23,12 @@ import { * * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the * 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 }> = [ @@ -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 router = useRouter(); const searchParams = useSearchParams(); @@ -121,7 +127,7 @@ export function ResultsList() { setIsLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paramsKey]); + }, [paramsKey, refreshKey]); useEffect(() => { load(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx index e92ccdd..7826d45 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx @@ -1,11 +1,12 @@ 'use client'; -import { Suspense } from 'react'; +import { Suspense, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { CoverageBanner } from './components/CoverageBanner'; import { FilterPanel } from './components/FilterPanel'; +import { PollNowButton } from './components/PollNowButton'; import { ResultsList } from './components/ResultsList'; import { SavedSearchBar } from './components/SavedSearchBar'; 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 * 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() { const t = useTranslations('tenderRadar'); const router = useRouter(); const searchParams = useSearchParams(); const tenderId = searchParams.get('tender'); + const [refreshKey, setRefreshKey] = useState(0); const closeDetail = () => { const next = new URLSearchParams(searchParams.toString()); @@ -58,30 +65,35 @@ function TenderRadarContent() { {t('page.title')} - {/* - * Einstellungen-Link. Absolute literal href (NOT the dynamic - * [category]/[moduleSlug] basePath): the tender-radar settings page - * only exists as the literal route /modules/tender-radar/settings — - * the dynamic settings route returns "Modul nicht gefunden" for this - * module, so the gear must point at the literal path directly. - */} - - - + {/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */} +
+ {/* + * Einstellungen-Link. Absolute literal href (NOT the dynamic + * [category]/[moduleSlug] basePath): the tender-radar settings page + * only exists as the literal route /modules/tender-radar/settings — + * the dynamic settings route returns "Modul nicht gefunden" for this + * module, so the gear must point at the literal path directly. + */} + + + + + setRefreshKey((k) => k + 1)} /> +
- + {tenderId && } diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index 3730803..0ef44a7 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -150,6 +150,23 @@ export async function listTenders( 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) — * feeds CoverageBanner so a thin/single-source result list is not diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 094710d..cb86795 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -576,7 +576,12 @@ "page": { "title": "Ausschreibungs-Radar", "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": { "colTitle": "Titel", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index da38464..a5a22e7 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -576,7 +576,12 @@ "page": { "title": "Tender Radar", "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": { "colTitle": "Title",