feat(14-05): convert tender-radar results-side components to useTranslations

Converts page.tsx, ResultsList, FilterPanel, SavedSearchBar, TenderDetail
and CoverageBanner from hardcoded German strings to
useTranslations('tenderRadar'). FilterPanel's Bundesland/CPV division
option labels are now looked up by stable code (NUTS-1 prefix / CPV
division code) while the underlying filter *value* sent to the backend
stays the canonical German string the API already matches against.
Portal display slugs (DÖE, DTVP, tender24, ...) in TenderDetail's
portalLabel() are left untranslated as proper-noun identifiers, not UI
copy. Updates the four affected component tests with a next-intl
useTranslations mock mirroring the marketplace test convention. Also
fixes an unrelated `t` parameter shadowing the translations function
inside ResultsList's triage batch-fetch (Rule 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:15:33 +02:00
parent d73171b052
commit 2ea02a2671
10 changed files with 338 additions and 230 deletions
@@ -18,6 +18,22 @@ vi.mock('@/lib/tender-radar-api', () => ({
fetchDenylistedPortals: (...args: unknown[]) => mockFetchDenylistedPortals(...args), fetchDenylistedPortals: (...args: unknown[]) => mockFetchDenylistedPortals(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx).
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
'coverage.label': 'Datenabdeckung:',
'coverage.text':
'Aktuell werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen fehlen noch — eine kurze Trefferliste ist daher kein Fehler.',
'coverage.denylistLabel': 'Manuell beobachten:',
'coverage.denylistText':
'Diese Portale lassen laut ihren Nutzungsbedingungen keinen automatisierten Zugriff zu und müssen daher manuell geprüft werden:',
};
return translations[key] ?? key;
},
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
mockFetchCoverage.mockReset(); mockFetchCoverage.mockReset();
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
fetchCoverage, fetchCoverage,
fetchDenylistedPortals, fetchDenylistedPortals,
@@ -28,6 +29,7 @@ import {
* public sources are ingested. * public sources are ingested.
*/ */
export function CoverageBanner() { export function CoverageBanner() {
const t = useTranslations('tenderRadar');
const [coverage, setCoverage] = useState<CoverageResponse | null>(null); const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
const [denylistedPortals, setDenylistedPortals] = useState<DenylistedPortal[]>([]); const [denylistedPortals, setDenylistedPortals] = useState<DenylistedPortal[]>([]);
@@ -53,17 +55,14 @@ export function CoverageBanner() {
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{showCoverageNote && ( {showCoverageNote && (
<p> <p>
<strong className="text-foreground">Datenabdeckung:</strong> Aktuell <strong className="text-foreground">{t('coverage.label')}</strong>{' '}
werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen {t('coverage.text')}
DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen
fehlen noch — eine kurze Trefferliste ist daher kein Fehler.
</p> </p>
)} )}
{denylistedPortals.length > 0 && ( {denylistedPortals.length > 0 && (
<p className={showCoverageNote ? 'mt-2' : undefined}> <p className={showCoverageNote ? 'mt-2' : undefined}>
<strong className="text-foreground">Manuell beobachten:</strong>{' '} <strong className="text-foreground">{t('coverage.denylistLabel')}</strong>{' '}
Diese Portale lassen laut ihren Nutzungsbedingungen keinen {t('coverage.denylistText')}{' '}
automatisierten Zugriff zu und müssen daher manuell geprüft werden:{' '}
{denylistedPortals.map((p, i) => ( {denylistedPortals.map((p, i) => (
<span key={p.portal}> <span key={p.portal}>
{i > 0 && ', '} {i > 0 && ', '}
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
/** /**
@@ -14,14 +15,18 @@ import { useRouter, useSearchParams } from 'next/navigation';
* `bundesland` (Plan 11-02), `cpv`, `valueMin`/`valueMax`/`includeNullValue` * `bundesland` (Plan 11-02), `cpv`, `valueMin`/`valueMax`/`includeNullValue`
* (Plan 11-03) follow the same convention. * (Plan 11-03) follow the same convention.
* *
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* Question 3, RESOLVED). * tenderRadar i18n namespace. `bundesland`'s option *value* stays the
* canonical German Bundesland name (apps/api's nutsPrefixFor() matches
* against that exact string server-side, D-10 does not change the API
* contract) — only the rendered *label* is translated via
* `filter.bundeslandOptions.{code}`.
*/ */
const SORT_OPTIONS: Array<{ value: string; label: string }> = [ const SORT_OPTIONS: Array<{ value: string; labelKey: string }> = [
{ value: 'published', label: 'Veröffentlichungsdatum' }, { value: 'published', labelKey: 'filter.sortPublished' },
{ value: 'deadline', label: 'Abgabefrist' }, { value: 'deadline', labelKey: 'filter.sortDeadline' },
{ value: 'value', label: 'Auftragswert' }, { value: 'value', labelKey: 'filter.sortValue' },
]; ];
/** /**
@@ -30,24 +35,27 @@ const SORT_OPTIONS: Array<{ value: string; label: string }> = [
* small, stable 16-entry list; RESEARCH's "kleine Konstante spiegeln" * small, stable 16-entry list; RESEARCH's "kleine Konstante spiegeln"
* option, FILTER-02 Plan 11-02 Task 3). Keep in sync if the Bundesland set * option, FILTER-02 Plan 11-02 Task 3). Keep in sync if the Bundesland set
* ever changes (it will not — this is the fixed EU NUTS-1/DE schema). * ever changes (it will not — this is the fixed EU NUTS-1/DE schema).
* `code` is the NUTS-1 prefix (matches the API's own lookup keys) used
* solely as the i18n key for the translated display label; `value` is the
* German Bundesland name sent to the backend, unchanged by i18n.
*/ */
const BUNDESLAND_OPTIONS: string[] = [ const BUNDESLAND_OPTIONS: Array<{ code: string; value: string }> = [
'Baden-Württemberg', { code: 'DE1', value: 'Baden-Württemberg' },
'Bayern', { code: 'DE2', value: 'Bayern' },
'Berlin', { code: 'DE3', value: 'Berlin' },
'Brandenburg', { code: 'DE4', value: 'Brandenburg' },
'Bremen', { code: 'DE5', value: 'Bremen' },
'Hamburg', { code: 'DE6', value: 'Hamburg' },
'Hessen', { code: 'DE7', value: 'Hessen' },
'Mecklenburg-Vorpommern', { code: 'DE8', value: 'Mecklenburg-Vorpommern' },
'Niedersachsen', { code: 'DE9', value: 'Niedersachsen' },
'Nordrhein-Westfalen', { code: 'DEA', value: 'Nordrhein-Westfalen' },
'Rheinland-Pfalz', { code: 'DEB', value: 'Rheinland-Pfalz' },
'Saarland', { code: 'DEC', value: 'Saarland' },
'Sachsen', { code: 'DED', value: 'Sachsen' },
'Sachsen-Anhalt', { code: 'DEE', value: 'Sachsen-Anhalt' },
'Schleswig-Holstein', { code: 'DEF', value: 'Schleswig-Holstein' },
'Thüringen', { code: 'DEG', value: 'Thüringen' },
]; ];
/** /**
@@ -55,65 +63,36 @@ const BUNDESLAND_OPTIONS: string[] = [
* (web and api are separate packages — same "kleine Konstante spiegeln" * (web and api are separate packages — same "kleine Konstante spiegeln"
* precedent as BUNDESLAND_OPTIONS above, Plan 11-02 Task 3 decision). Only * precedent as BUNDESLAND_OPTIONS above, Plan 11-02 Task 3 decision). Only
* the 2-digit CPV Division level ships here (D-03, Open Question 2 * the 2-digit CPV Division level ships here (D-03, Open Question 2
* RESOLVED) — deliberately not the full EU CPV vocabulary. * RESOLVED) — deliberately not the full EU CPV vocabulary. `code` is both
* the filter value sent to the backend AND the i18n key under
* `filter.cpvDivisions.{code}` for the translated label.
*/ */
const CPV_DIVISION_OPTIONS: Array<{ code: string; label: string }> = [ const CPV_DIVISION_CODES: string[] = [
{ code: '03', label: 'Landwirtschaftliche, fischereiwirtschaftliche, forstwirtschaftliche und verwandte Erzeugnisse' }, '03', '09', '14', '15', '16', '18', '19', '22', '24', '30',
{ code: '09', label: 'Mineralölerzeugnisse, Brennstoffe, Elektrizität und andere Energiequellen' }, '31', '32', '33', '34', '35', '37', '38', '39', '41', '42',
{ code: '14', label: 'Bergbauerzeugnisse, Grundstoffe und verwandte Erzeugnisse' }, '43', '44', '45', '48', '50', '51', '55', '60', '63', '64',
{ code: '15', label: 'Nahrungsmittel, Getränke, Tabak und verwandte Erzeugnisse' }, '65', '66', '70', '71', '72', '73', '75', '76', '77', '79',
{ code: '16', label: 'Land- und forstwirtschaftliche Maschinen' }, '80', '85', '90', '92', '98',
{ code: '18', label: 'Bekleidung, Schuhe, Reiseartikel und Zubehör' },
{ code: '19', label: 'Leder- und Textilwaren, Kunststoff- und Gummierzeugnisse' },
{ code: '22', label: 'Druckerzeugnisse und verwandte Erzeugnisse' },
{ code: '24', label: 'Chemische Erzeugnisse' },
{ code: '30', label: 'Büromaschinen, Datenverarbeitungsgeräte, -ausrüstung und -material, ausgenommen Möbel und Softwarepakete' },
{ code: '31', label: 'Elektrische Maschinen, Geräte, Ausrüstungen und Verbrauchsartikel; Beleuchtung' },
{ code: '32', label: 'Rundfunk-, Fernseh-, Kommunikations-, Telekommunikations- und verwandte Geräte' },
{ code: '33', label: 'Medizinische Apparate, pharmazeutische Erzeugnisse und Körperpflegeprodukte' },
{ code: '34', label: 'Transportausrüstung und Transportzubehör' },
{ code: '35', label: 'Sicherheits-, Feuerlösch-, Polizei- und Verteidigungsausrüstung' },
{ code: '37', label: 'Musikinstrumente, Sportartikel, Spiele, Spielzeug, Kunsthandwerk, Kunstmaterial und Zubehör' },
{ code: '38', label: 'Laborgeräte, optische und Präzisionsgeräte (ausgenommen Gläser)' },
{ code: '39', label: 'Möbel (einschließlich Büromöbel), Einrichtungsgegenstände, Haushaltsgeräte (ausgenommen Beleuchtung) und Reinigungsmittel' },
{ code: '41', label: 'Gesammeltes und gereinigtes Wasser' },
{ code: '42', label: 'Industriemaschinen' },
{ code: '43', label: 'Maschinen für Bergbau, Steinbruch, Bauwesen' },
{ code: '44', label: 'Baustoffe und zugehörige Artikel' },
{ code: '45', label: 'Bauarbeiten' },
{ code: '48', label: 'Softwarepaket und Informationssysteme' },
{ code: '50', label: 'Reparatur- und Wartungsdienste' },
{ code: '51', label: 'Installationsdienste (ausgenommen Software)' },
{ code: '55', label: 'Beherbergungs-, Gaststätten- und Handelsdienstleistungen' },
{ code: '60', label: 'Transportdienstleistungen (ohne Abfalltransport)' },
{ code: '63', label: 'Neben- und Hilfstätigkeiten im Verkehr; Reisebüros' },
{ code: '64', label: 'Post- und Telekommunikationsdienste' },
{ code: '65', label: 'Versorgungsbetriebe' },
{ code: '66', label: 'Finanz- und Versicherungsdienstleistungen' },
{ code: '70', label: 'Dienstleistungen im Zusammenhang mit Immobilien' },
{ code: '71', label: 'Architektur-, Bau-, Ingenieur- und Prüfdienste' },
{ code: '72', label: 'IT-Dienste: Beratung, Software-Entwicklung, Internet und Hilfestellung' },
{ code: '73', label: 'Forschungs- und Entwicklungsdienste sowie zugehörige Beratung' },
{ code: '75', label: 'Öffentliche Verwaltung, Verteidigung und Sozialversicherung' },
{ code: '76', label: 'Dienstleistungen im Zusammenhang mit der Öl- und Gasindustrie' },
{ code: '77', label: 'Landwirtschaftliche, forstwirtschaftliche, gartenbauliche Dienste, Aquakultur und Imkerei' },
{ code: '79', label: 'Unternehmensbezogene Dienstleistungen: Recht, Marketing, Consulting, Personal, Druck und Sicherheit' },
{ code: '80', label: 'Dienstleistungen des Bildungswesens und der Ausbildung' },
{ code: '85', label: 'Dienste im Gesundheits- und Sozialwesen' },
{ code: '90', label: 'Abwasser- und Abfallbeseitigung, Reinigung und Umweltschutzdienste' },
{ code: '92', label: 'Dienstleistungen der Freizeitgestaltung, der Kultur und des Sports' },
{ code: '98', label: 'Sonstige Gemeinschafts-, Sozial- und Individualdienstleistungen' },
]; ];
function labelForCpvDivision(code: string): string {
return CPV_DIVISION_OPTIONS.find((d) => d.code === code)?.label ?? code;
}
export function FilterPanel() { export function FilterPanel() {
const t = useTranslations('tenderRadar');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [cpvSearchText, setCpvSearchText] = useState(''); const [cpvSearchText, setCpvSearchText] = useState('');
const cpvOptions = useMemo(
() =>
CPV_DIVISION_CODES.map((code) => ({
code,
label: t(`filter.cpvDivisions.${code}`),
})),
[t],
);
const labelForCpvDivision = (code: string): string =>
cpvOptions.find((d) => d.code === code)?.label ?? code;
const q = searchParams.get('q') ?? ''; const q = searchParams.get('q') ?? '';
const sort = searchParams.get('sort') ?? 'published'; const sort = searchParams.get('sort') ?? 'published';
// openOnly defaults to true (D-04) — only an explicit "false" turns it off. // openOnly defaults to true (D-04) — only an explicit "false" turns it off.
@@ -166,12 +145,12 @@ export function FilterPanel() {
const cpvMatches = useMemo(() => { const cpvMatches = useMemo(() => {
const text = cpvSearchText.trim().toLowerCase(); const text = cpvSearchText.trim().toLowerCase();
if (!text) return []; if (!text) return [];
return CPV_DIVISION_OPTIONS.filter( return cpvOptions.filter(
(d) => (d) =>
!selectedCpv.includes(d.code) && !selectedCpv.includes(d.code) &&
(d.code.includes(text) || d.label.toLowerCase().includes(text)), (d.code.includes(text) || d.label.toLowerCase().includes(text)),
).slice(0, 8); ).slice(0, 8);
}, [cpvSearchText, selectedCpv]); }, [cpvSearchText, selectedCpv, cpvOptions]);
const inputCls = const inputCls =
'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground'; 'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground';
@@ -182,12 +161,12 @@ export function FilterPanel() {
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1"> <div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}> <label htmlFor="tr-filter-q" className={labelCls}>
Suche {t('filter.searchLabel')}
</label> </label>
<input <input
id="tr-filter-q" id="tr-filter-q"
type="text" type="text"
placeholder="Titel oder Auftraggeber…" placeholder={t('filter.searchPlaceholder')}
value={q} value={q}
onChange={(e) => setParam('q', e.target.value)} onChange={(e) => setParam('q', e.target.value)}
className={inputCls} className={inputCls}
@@ -196,7 +175,7 @@ export function FilterPanel() {
<div> <div>
<label htmlFor="tr-filter-sort" className={labelCls}> <label htmlFor="tr-filter-sort" className={labelCls}>
Sortierung {t('filter.sortLabel')}
</label> </label>
<select <select
id="tr-filter-sort" id="tr-filter-sort"
@@ -206,7 +185,7 @@ export function FilterPanel() {
> >
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {t(opt.labelKey)}
</option> </option>
))} ))}
</select> </select>
@@ -221,7 +200,7 @@ export function FilterPanel() {
className="h-4 w-4" className="h-4 w-4"
/> />
<label htmlFor="tr-filter-open-only" className="text-sm text-foreground"> <label htmlFor="tr-filter-open-only" className="text-sm text-foreground">
Nur noch offene {t('filter.openOnlyLabel')}
</label> </label>
</div> </div>
@@ -234,7 +213,7 @@ export function FilterPanel() {
className="h-4 w-4" className="h-4 w-4"
/> />
<label htmlFor="tr-filter-fav-only" className="text-sm text-foreground"> <label htmlFor="tr-filter-fav-only" className="text-sm text-foreground">
Nur Favoriten/Merkliste {t('filter.favOnlyLabel')}
</label> </label>
</div> </div>
</div> </div>
@@ -242,7 +221,7 @@ export function FilterPanel() {
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label htmlFor="tr-filter-deadline-from" className={labelCls}> <label htmlFor="tr-filter-deadline-from" className={labelCls}>
Abgabefrist von {t('filter.deadlineFromLabel')}
</label> </label>
<input <input
id="tr-filter-deadline-from" id="tr-filter-deadline-from"
@@ -254,7 +233,7 @@ export function FilterPanel() {
</div> </div>
<div> <div>
<label htmlFor="tr-filter-deadline-to" className={labelCls}> <label htmlFor="tr-filter-deadline-to" className={labelCls}>
Abgabefrist bis {t('filter.deadlineToLabel')}
</label> </label>
<input <input
id="tr-filter-deadline-to" id="tr-filter-deadline-to"
@@ -269,14 +248,14 @@ export function FilterPanel() {
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label htmlFor="tr-filter-plz" className={labelCls}> <label htmlFor="tr-filter-plz" className={labelCls}>
PLZ {t('filter.plzLabel')}
</label> </label>
<input <input
id="tr-filter-plz" id="tr-filter-plz"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
maxLength={5} maxLength={5}
placeholder="z. B. 10" placeholder={t('filter.plzPlaceholder')}
value={plz} value={plz}
onChange={(e) => setParam('plz', e.target.value)} onChange={(e) => setParam('plz', e.target.value)}
className="h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground" className="h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground"
@@ -285,7 +264,7 @@ export function FilterPanel() {
<div className="min-w-[220px]"> <div className="min-w-[220px]">
<label htmlFor="tr-filter-bundesland" className={labelCls}> <label htmlFor="tr-filter-bundesland" className={labelCls}>
Bundesland {t('filter.bundeslandLabel')}
</label> </label>
<select <select
id="tr-filter-bundesland" id="tr-filter-bundesland"
@@ -293,10 +272,10 @@ export function FilterPanel() {
onChange={(e) => setParam('bundesland', e.target.value || null)} onChange={(e) => setParam('bundesland', e.target.value || null)}
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground" className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
> >
<option value="">Alle Bundesländer</option> <option value="">{t('filter.bundeslandAll')}</option>
{BUNDESLAND_OPTIONS.map((name) => ( {BUNDESLAND_OPTIONS.map((b) => (
<option key={name} value={name}> <option key={b.value} value={b.value}>
{name} {t(`filter.bundeslandOptions.${b.code}`)}
</option> </option>
))} ))}
</select> </select>
@@ -306,12 +285,12 @@ export function FilterPanel() {
<div className="flex flex-wrap items-start gap-4"> <div className="flex flex-wrap items-start gap-4">
<div className="relative min-w-[280px] flex-1"> <div className="relative min-w-[280px] flex-1">
<label htmlFor="tr-filter-cpv" className={labelCls}> <label htmlFor="tr-filter-cpv" className={labelCls}>
Branche (CPV) {t('filter.cpvLabel')}
</label> </label>
<input <input
id="tr-filter-cpv" id="tr-filter-cpv"
type="text" type="text"
placeholder="z. B. Bau, IT, Gesundheit…" placeholder={t('filter.cpvPlaceholder')}
value={cpvSearchText} value={cpvSearchText}
onChange={(e) => setCpvSearchText(e.target.value)} onChange={(e) => setCpvSearchText(e.target.value)}
className={inputCls} className={inputCls}
@@ -345,7 +324,7 @@ export function FilterPanel() {
{code} – {labelForCpvDivision(code)} {code} – {labelForCpvDivision(code)}
<button <button
type="button" type="button"
aria-label={`Division ${code} entfernen`} aria-label={t('filter.cpvRemoveAria', { code })}
onClick={() => toggleCpvDivision(code)} onClick={() => toggleCpvDivision(code)}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
@@ -359,14 +338,14 @@ export function FilterPanel() {
<div> <div>
<label htmlFor="tr-filter-value-min" className={labelCls}> <label htmlFor="tr-filter-value-min" className={labelCls}>
Auftragswert von (€) {t('filter.valueMinLabel')}
</label> </label>
<input <input
id="tr-filter-value-min" id="tr-filter-value-min"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
min={0} min={0}
placeholder="min." placeholder={t('filter.valueMinPlaceholder')}
value={valueMin} value={valueMin}
onChange={(e) => setParam('valueMin', e.target.value)} onChange={(e) => setParam('valueMin', e.target.value)}
className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground" className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground"
@@ -374,14 +353,14 @@ export function FilterPanel() {
</div> </div>
<div> <div>
<label htmlFor="tr-filter-value-max" className={labelCls}> <label htmlFor="tr-filter-value-max" className={labelCls}>
Auftragswert bis (€) {t('filter.valueMaxLabel')}
</label> </label>
<input <input
id="tr-filter-value-max" id="tr-filter-value-max"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
min={0} min={0}
placeholder="max." placeholder={t('filter.valueMaxPlaceholder')}
value={valueMax} value={valueMax}
onChange={(e) => setParam('valueMax', e.target.value)} onChange={(e) => setParam('valueMax', e.target.value)}
className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground" className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground"
@@ -401,7 +380,7 @@ export function FilterPanel() {
htmlFor="tr-filter-include-null-value" htmlFor="tr-filter-include-null-value"
className="text-sm text-foreground" className="text-sm text-foreground"
> >
Ohne Wertangabe einschließen {t('filter.includeNullValueLabel')}
</label> </label>
</div> </div>
</div> </div>
@@ -20,6 +20,37 @@ vi.mock('@/lib/tender-radar-api', () => ({
setTriage: (...args: unknown[]) => mockSetTriage(...args), setTriage: (...args: unknown[]) => mockSetTriage(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx): a flat key -> German-copy lookup
// table so assertions against the actual rendered copy keep working.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
'results.colTitle': 'Titel',
'results.colBuyer': 'Auftraggeber',
'results.colDeadline': 'Frist',
'results.colValue': 'Wert',
'results.colPublished': 'Veröffentlicht',
'results.colTriage': 'Triage',
'results.noValue': 'keine Wertangabe',
'results.error': 'Ausschreibungen konnten nicht geladen werden.',
'results.emptyTitle': 'Keine Ausschreibungen gefunden.',
'results.emptyBody': 'Passen Sie die Filter an oder deaktivieren Sie „nur noch offene".',
'results.markUnread': 'Als ungelesen markieren',
'results.markRead': 'Als gelesen markieren',
'results.readLabel': 'Gelesen',
'results.unreadLabel': 'Ungelesen',
'results.removeFavorite': 'Favorit entfernen',
'results.setFavorite': 'Favorit setzen',
'results.favoriteOn': '★ Favorit',
'results.favoriteOff': '☆ Favorit',
'results.prevPage': '← Zurück',
'results.nextPage': 'Weiter →',
};
return translations[key] ?? key;
},
}));
const mockReplace = vi.fn(); const mockReplace = vi.fn();
let mockSearchParams = new URLSearchParams(); let mockSearchParams = new URLSearchParams();
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { import {
fetchTriage, fetchTriage,
@@ -18,13 +19,16 @@ import {
* sie -> listTenders()") and re-fetches whenever the URL changes. * sie -> listTenders()") and re-fetches whenever the URL changes.
* *
* D-05 (Pflichtkriterium): a row with estimatedValue=null is NEVER hidden — * D-05 (Pflichtkriterium): a row with estimatedValue=null is NEVER hidden —
* it renders the literal "keine Wertangabe" label instead of blank/0. * it renders the translated "no value stated" label instead of blank/0.
*
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace.
*/ */
const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; label: string }> = [ const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; labelKey: string }> = [
{ key: 'deadline', label: 'Frist' }, { key: 'deadline', labelKey: 'results.colDeadline' },
{ key: 'value', label: 'Wert' }, { key: 'value', labelKey: 'results.colValue' },
{ key: 'published', label: 'Veröffentlicht' }, { key: 'published', labelKey: 'results.colPublished' },
]; ];
const DEFAULT_LIMIT = 20; const DEFAULT_LIMIT = 20;
@@ -44,13 +48,13 @@ function formatDate(value: string | null): string {
/** /**
* D-05: estimatedValue arrives as a string (Prisma Decimal) or null. * D-05: estimatedValue arrives as a string (Prisma Decimal) or null.
* Renders "keine Wertangabe" for null/unparseable values — never blank, * Renders the translated "no value stated" label for null/unparseable
* never 0, and never a thrown error. * values — never blank, never 0, and never a thrown error.
*/ */
function formatValue(value: string | null): string { function formatValue(value: string | null, noValueLabel: string): string {
if (value === null || value === undefined) return 'keine Wertangabe'; if (value === null || value === undefined) return noValueLabel;
const n = Number(value); const n = Number(value);
if (!Number.isFinite(n)) return 'keine Wertangabe'; if (!Number.isFinite(n)) return noValueLabel;
return n.toLocaleString('de-DE', { return n.toLocaleString('de-DE', {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
@@ -59,6 +63,7 @@ function formatValue(value: string | null): string {
} }
export function ResultsList() { export function ResultsList() {
const t = useTranslations('tenderRadar');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const paramsKey = searchParams.toString(); const paramsKey = searchParams.toString();
@@ -90,7 +95,7 @@ export function ResultsList() {
// key_link: "GET /triage batch → merge in ResultsList"). Triage is a // key_link: "GET /triage batch → merge in ResultsList"). Triage is a
// supplementary per-user overlay — a failed batch-load must not block // supplementary per-user overlay — a failed batch-load must not block
// rendering the tender list itself. // rendering the tender list itself.
const ids = data.items.map((t) => t.id); const ids = data.items.map((item) => item.id);
try { try {
const rows = await fetchTriage(ids); const rows = await fetchTriage(ids);
if (Array.isArray(rows)) { if (Array.isArray(rows)) {
@@ -110,13 +115,12 @@ export function ResultsList() {
} }
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('results.error'),
? err.message
: 'Ausschreibungen konnten nicht geladen werden.',
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paramsKey]); }, [paramsKey]);
useEffect(() => { useEffect(() => {
@@ -198,8 +202,8 @@ export function ResultsList() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/30"> <thead className="bg-muted/30">
<tr> <tr>
<th className={thStaticCls}>Titel</th> <th className={thStaticCls}>{t('results.colTitle')}</th>
<th className={thStaticCls}>Auftraggeber</th> <th className={thStaticCls}>{t('results.colBuyer')}</th>
{SORT_HEADERS.map((h) => ( {SORT_HEADERS.map((h) => (
<th <th
key={h.key} key={h.key}
@@ -207,11 +211,11 @@ export function ResultsList() {
onClick={() => handleSort(h.key)} onClick={() => handleSort(h.key)}
aria-sort={currentSort === h.key ? 'ascending' : 'none'} aria-sort={currentSort === h.key ? 'ascending' : 'none'}
> >
{h.label} {t(h.labelKey)}
{currentSort === h.key ? ' ▾' : ''} {currentSort === h.key ? ' ▾' : ''}
</th> </th>
))} ))}
<th className={thStaticCls}>Triage</th> <th className={thStaticCls}>{t('results.colTriage')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -235,11 +239,10 @@ export function ResultsList() {
<tr> <tr>
<td colSpan={6} 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. {t('results.emptyTitle')}
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Passen Sie die Filter an oder deaktivieren Sie „nur noch {t('results.emptyBody')}
offene".
</p> </p>
</td> </td>
</tr> </tr>
@@ -262,7 +265,9 @@ export function ResultsList() {
</td> </td>
<td className="px-4 py-3">{tender.buyerName ?? '–'}</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">{formatDate(tender.deadlineAt)}</td>
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td> <td className="px-4 py-3">
{formatValue(tender.estimatedValue, t('results.noValue'))}
</td>
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td> <td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -272,17 +277,17 @@ export function ResultsList() {
aria-pressed={rowTriage.isRead} aria-pressed={rowTriage.isRead}
aria-label={ aria-label={
rowTriage.isRead rowTriage.isRead
? 'Als ungelesen markieren' ? t('results.markUnread')
: 'Als gelesen markieren' : t('results.markRead')
} }
title={ title={
rowTriage.isRead rowTriage.isRead
? 'Als ungelesen markieren' ? t('results.markUnread')
: 'Als gelesen markieren' : t('results.markRead')
} }
className="rounded border border-border px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted" className="rounded border border-border px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
> >
{rowTriage.isRead ? 'Gelesen' : 'Ungelesen'} {rowTriage.isRead ? t('results.readLabel') : t('results.unreadLabel')}
</button> </button>
<button <button
type="button" type="button"
@@ -290,13 +295,13 @@ export function ResultsList() {
aria-pressed={rowTriage.isFavorite} aria-pressed={rowTriage.isFavorite}
aria-label={ aria-label={
rowTriage.isFavorite rowTriage.isFavorite
? 'Favorit entfernen' ? t('results.removeFavorite')
: 'Favorit setzen' : t('results.setFavorite')
} }
title={ title={
rowTriage.isFavorite rowTriage.isFavorite
? 'Favorit entfernen' ? t('results.removeFavorite')
: 'Favorit setzen' : t('results.setFavorite')
} }
className={`rounded border px-2 py-1 text-xs transition-colors hover:bg-muted ${ className={`rounded border px-2 py-1 text-xs transition-colors hover:bg-muted ${
rowTriage.isFavorite rowTriage.isFavorite
@@ -304,7 +309,7 @@ export function ResultsList() {
: 'border-border text-foreground' : 'border-border text-foreground'
}`} }`}
> >
{rowTriage.isFavorite ? '★ Favorit' : '☆ Favorit'} {rowTriage.isFavorite ? t('results.favoriteOn') : t('results.favoriteOff')}
</button> </button>
</div> </div>
</td> </td>
@@ -318,8 +323,11 @@ export function ResultsList() {
{!isLoading && total > limit && ( {!isLoading && total > limit && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border"> <div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Zeige {(page - 1) * limit + 1}–{Math.min(page * limit, total)} von{' '} {t('results.showingRange', {
{total} from: String((page - 1) * limit + 1),
to: String(Math.min(page * limit, total)),
total: String(total),
})}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -328,7 +336,7 @@ export function ResultsList() {
disabled={page <= 1} disabled={page <= 1}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed" className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
> >
← Zurück {t('results.prevPage')}
</button> </button>
<button <button
type="button" type="button"
@@ -336,7 +344,7 @@ export function ResultsList() {
disabled={page >= totalPages} disabled={page >= totalPages}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed" className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
> >
Weiter → {t('results.nextPage')}
</button> </button>
</div> </div>
</div> </div>
@@ -27,6 +27,45 @@ vi.mock('@/lib/tender-radar-api', () => ({
deleteSavedSearch: (...args: unknown[]) => mockDeleteSavedSearch(...args), deleteSavedSearch: (...args: unknown[]) => mockDeleteSavedSearch(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx): a flat key -> German-copy lookup
// table with {param} interpolation support.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
'savedSearch.title': 'Suchprofile',
'savedSearch.saveAsLabel': 'Aktuelle Filter speichern als',
'savedSearch.namePlaceholder': 'Name des Suchprofils…',
'savedSearch.save': 'Speichern',
'savedSearch.cancel': 'Abbrechen',
'savedSearch.loading': 'Lädt…',
'savedSearch.empty': 'Noch keine Suchprofile gespeichert.',
'savedSearch.loadTitle': 'Suchprofil laden',
'savedSearch.instantAlertTitle': 'Sofort-Alert per E-Mail bei neuen Treffern dieses Profils',
'savedSearch.instantAlertLabel': 'Sofort-Alert',
'savedSearch.instantAlertAria': 'Sofort-Alert für {name}',
'savedSearch.renameTitle': 'Umbenennen',
'savedSearch.renameAria': '{name} umbenennen',
'savedSearch.renameConfirmAria': '{name} umbenennen bestätigen',
'savedSearch.renameCancelAria': 'Umbenennen abbrechen',
'savedSearch.deleteTitle': 'Löschen',
'savedSearch.deleteAria': '{name} löschen',
'savedSearch.errorLoad': 'Suchprofile konnten nicht geladen werden.',
'savedSearch.errorSave': 'Suchprofil konnte nicht gespeichert werden.',
'savedSearch.errorRename': 'Suchprofil konnte nicht umbenannt werden.',
'savedSearch.errorDelete': 'Suchprofil konnte nicht gelöscht werden.',
'savedSearch.errorToggleAlert': 'Sofort-Alert konnte nicht umgeschaltet werden.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, v);
}
}
return result;
},
}));
const mockReplace = vi.fn(); const mockReplace = vi.fn();
let mockSearchParams = new URLSearchParams(); let mockSearchParams = new URLSearchParams();
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { import {
createSavedSearch, createSavedSearch,
@@ -36,8 +37,8 @@ import {
* a profile field alongside name/filters, not a filter key — it never * a profile field alongside name/filters, not a filter key — it never
* touches the URL searchParams filters contract above. * touches the URL searchParams filters contract above.
* *
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* Question 3, RESOLVED), same convention as FilterPanel/ResultsList. * tenderRadar i18n namespace.
*/ */
const SINGLE_VALUE_FILTER_KEYS = [ const SINGLE_VALUE_FILTER_KEYS = [
@@ -110,6 +111,7 @@ export function filtersToSearchParams(
} }
export function SavedSearchBar() { export function SavedSearchBar() {
const t = useTranslations('tenderRadar');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -128,9 +130,7 @@ export function SavedSearchBar() {
setProfiles(data); setProfiles(data);
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('savedSearch.errorLoad'),
? err.message
: 'Suchprofile konnten nicht geladen werden.',
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -153,9 +153,7 @@ export function SavedSearchBar() {
await load(); await load();
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('savedSearch.errorSave'),
? err.message
: 'Suchprofil konnte nicht gespeichert werden.',
); );
} }
}; };
@@ -185,9 +183,7 @@ export function SavedSearchBar() {
await load(); await load();
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('savedSearch.errorRename'),
? err.message
: 'Suchprofil konnte nicht umbenannt werden.',
); );
} }
}; };
@@ -199,9 +195,7 @@ export function SavedSearchBar() {
await load(); await load();
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('savedSearch.errorDelete'),
? err.message
: 'Suchprofil konnte nicht gelöscht werden.',
); );
} }
}; };
@@ -213,16 +207,14 @@ export function SavedSearchBar() {
await load(); await load();
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('savedSearch.errorToggleAlert'),
? err.message
: 'Sofort-Alert konnte nicht umgeschaltet werden.',
); );
} }
}; };
return ( return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3"> <div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3">
<h2 className="text-sm font-semibold text-foreground">Suchprofile</h2> <h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
<div className="min-w-[220px] flex-1"> <div className="min-w-[220px] flex-1">
@@ -230,12 +222,12 @@ export function SavedSearchBar() {
htmlFor="tr-saved-search-name" htmlFor="tr-saved-search-name"
className="mb-1 block text-sm text-foreground" className="mb-1 block text-sm text-foreground"
> >
Aktuelle Filter speichern als {t('savedSearch.saveAsLabel')}
</label> </label>
<input <input
id="tr-saved-search-name" id="tr-saved-search-name"
type="text" type="text"
placeholder="Name des Suchprofils…" placeholder={t('savedSearch.namePlaceholder')}
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground" className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
@@ -247,17 +239,17 @@ export function SavedSearchBar() {
disabled={!newName.trim()} disabled={!newName.trim()}
className="h-9 rounded border border-border px-3 text-sm text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" className="h-9 rounded border border-border px-3 text-sm text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
> >
Speichern {t('savedSearch.save')}
</button> </button>
</div> </div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
{isLoading ? ( {isLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p> <p className="text-sm text-muted-foreground">{t('savedSearch.loading')}</p>
) : profiles.length === 0 ? ( ) : profiles.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Noch keine Suchprofile gespeichert. {t('savedSearch.empty')}
</p> </p>
) : ( ) : (
<ul className="flex flex-wrap gap-2"> <ul className="flex flex-wrap gap-2">
@@ -276,19 +268,19 @@ export function SavedSearchBar() {
/> />
<button <button
type="button" type="button"
aria-label={`${profile.name} umbenennen bestätigen`} aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
onClick={() => confirmRename(profile.id)} onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium text-primary hover:underline"
> >
Speichern {t('savedSearch.save')}
</button> </button>
<button <button
type="button" type="button"
aria-label="Umbenennen abbrechen" aria-label={t('savedSearch.renameCancelAria')}
onClick={cancelRename} onClick={cancelRename}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
Abbrechen {t('savedSearch.cancel')}
</button> </button>
</> </>
) : ( ) : (
@@ -296,41 +288,41 @@ export function SavedSearchBar() {
<button <button
type="button" type="button"
onClick={() => handleLoad(profile)} onClick={() => handleLoad(profile)}
title="Suchprofil laden" title={t('savedSearch.loadTitle')}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{profile.name} {profile.name}
</button> </button>
<label <label
className="flex items-center gap-1 text-xs text-muted-foreground" className="flex items-center gap-1 text-xs text-muted-foreground"
title="Sofort-Alert per E-Mail bei neuen Treffern dieses Profils" title={t('savedSearch.instantAlertTitle')}
> >
<input <input
type="checkbox" type="checkbox"
aria-label={`Sofort-Alert für ${profile.name}`} aria-label={t('savedSearch.instantAlertAria', { name: profile.name })}
checked={profile.instantAlert} checked={profile.instantAlert}
onChange={() => handleToggleInstantAlert(profile)} onChange={() => handleToggleInstantAlert(profile)}
className="h-3.5 w-3.5" className="h-3.5 w-3.5"
/> />
Sofort-Alert {t('savedSearch.instantAlertLabel')}
</label> </label>
<button <button
type="button" type="button"
aria-label={`${profile.name} umbenennen`} aria-label={t('savedSearch.renameAria', { name: profile.name })}
title="Umbenennen" title={t('savedSearch.renameTitle')}
onClick={() => startRename(profile)} onClick={() => startRename(profile)}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
Umbenennen {t('savedSearch.renameTitle')}
</button> </button>
<button <button
type="button" type="button"
aria-label={`${profile.name} löschen`} aria-label={t('savedSearch.deleteAria', { name: profile.name })}
title="Löschen" title={t('savedSearch.deleteTitle')}
onClick={() => handleDelete(profile)} onClick={() => handleDelete(profile)}
className="text-xs text-muted-foreground hover:text-destructive" className="text-xs text-muted-foreground hover:text-destructive"
> >
Löschen {t('savedSearch.deleteTitle')}
</button> </button>
</> </>
)} )}
@@ -24,6 +24,46 @@ vi.mock('@/lib/tender-radar-api', () => ({
getTender: (...args: unknown[]) => mockGetTender(...args), getTender: (...args: unknown[]) => mockGetTender(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx): a flat key -> German-copy lookup
// table with {param} interpolation support.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
'detail.dialogAria': 'Ausschreibungsdetails',
'detail.title': 'Ausschreibungsdetails',
'detail.close': 'Schließen',
'detail.loading': 'Lädt…',
'detail.error': 'Ausschreibung konnte nicht geladen werden.',
'detail.fieldTitle': 'Titel',
'detail.fieldBuyer': 'Auftraggeber',
'detail.fieldRegion': 'Region / PLZ / Bundesland',
'detail.fieldCpv': 'CPV-Codes',
'detail.noCpv': 'keine CPV-Codes angegeben',
'detail.fieldDeadline': 'Abgabefrist',
'detail.noDeadline': 'keine Frist angegeben',
'detail.fieldValue': 'Geschätzter Auftragswert',
'detail.noValue': 'keine Wertangabe',
'detail.fieldProcedureType': 'Verfahrensart',
'detail.fieldStatus': 'Status',
'detail.fieldPublished': 'Veröffentlicht',
'detail.mirrorNotice':
'Vergabeunterlagen werden nicht lokal gespiegelt — der folgende Link führt direkt zum Original auf dem Quellportal.',
'detail.sourceLink': '{portal}: Zur Ausschreibung auf dem Quellportal →',
'detail.noSourceLink': '{portal}: Kein Quell-Link verfügbar.',
'detail.sourceLinkFallback': 'Zur Ausschreibung auf dem Quellportal →',
'detail.noSourceLinkFallback': 'Kein Quell-Link verfügbar.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, v);
}
}
return result;
},
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
mockGetTender.mockReset(); mockGetTender.mockReset();
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { getTender, type Tender } from '@/lib/tender-radar-api'; import { getTender, type Tender } from '@/lib/tender-radar-api';
/** /**
@@ -27,8 +28,8 @@ import { getTender, type Tender } from '@/lib/tender-radar-api';
* Fallback auf den einzelnen `sourceUrl`-Block, falls `sources` fehlt * Fallback auf den einzelnen `sourceUrl`-Block, falls `sources` fehlt
* oder leer ist (Alt-Tender / API-Antworten ohne include). * oder leer ist (Alt-Tender / API-Antworten ohne include).
* *
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* Question 3, RESOLVED), same convention as ResultsList/FilterPanel. * tenderRadar i18n namespace.
*/ */
interface TenderDetailProps { interface TenderDetailProps {
@@ -36,22 +37,22 @@ interface TenderDetailProps {
onClose: () => void; onClose: () => void;
} }
/** D-05 gilt auch für die Detailansicht: NIE ausblenden, NIE Fehler werfen. */ /** D-05 applies to the detail view too: NEVER hide, NEVER throw. */
function formatDeadline(value: string | null): string { function formatDeadline(value: string | null, noDeadlineLabel: string): string {
if (!value) return 'keine Frist angegeben'; if (!value) return noDeadlineLabel;
const d = new Date(value); const d = new Date(value);
if (isNaN(d.getTime())) return 'keine Frist angegeben'; if (isNaN(d.getTime())) return noDeadlineLabel;
return d.toLocaleDateString('de-DE'); return d.toLocaleDateString('de-DE');
} }
/** /**
* `estimatedValue` kommt als String (Prisma Decimal) oder null — niemals * `estimatedValue` arrives as a string (Prisma Decimal) or null — never
* blind `Number()`-koerzieren (gleiche Regel wie ResultsList.formatValue). * blindly coerce with `Number()` (same rule as ResultsList.formatValue).
*/ */
function formatValue(value: string | null): string { function formatValue(value: string | null, noValueLabel: string): string {
if (value === null || value === undefined) return 'keine Wertangabe'; if (value === null || value === undefined) return noValueLabel;
const n = Number(value); const n = Number(value);
if (!Number.isFinite(n)) return 'keine Wertangabe'; if (!Number.isFinite(n)) return noValueLabel;
return n.toLocaleString('de-DE', { return n.toLocaleString('de-DE', {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
@@ -87,6 +88,7 @@ function portalLabel(sourcePortal: string): string {
} }
export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
const t = useTranslations('tenderRadar');
const [tender, setTender] = useState<Tender | null>(null); const [tender, setTender] = useState<Tender | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -104,9 +106,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
.catch((err) => { .catch((err) => {
if (!cancelled) { if (!cancelled) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('detail.error'),
? err.message
: 'Ausschreibung konnte nicht geladen werden.',
); );
} }
}) })
@@ -124,12 +124,12 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
className="fixed inset-0 z-50 flex justify-end bg-black/40" className="fixed inset-0 z-50 flex justify-end bg-black/40"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Ausschreibungsdetails" aria-label={t('detail.dialogAria')}
> >
{/* Backdrop click closes the drawer, mirroring standard overlay UX */} {/* Backdrop click closes the drawer, mirroring standard overlay UX */}
<button <button
type="button" type="button"
aria-label="Schließen" aria-label={t('detail.close')}
onClick={onClose} onClick={onClose}
className="absolute inset-0 h-full w-full cursor-default" className="absolute inset-0 h-full w-full cursor-default"
tabIndex={-1} tabIndex={-1}
@@ -138,37 +138,37 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
<div className="relative h-full w-full max-w-lg overflow-y-auto border-l border-border bg-background p-6 shadow-xl"> <div className="relative h-full w-full max-w-lg overflow-y-auto border-l border-border bg-background p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold tracking-tight"> <h2 className="text-lg font-bold tracking-tight">
Ausschreibungsdetails {t('detail.title')}
</h2> </h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted" className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted"
> >
Schließen {t('detail.close')}
</button> </button>
</div> </div>
{isLoading ? ( {isLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p> <p className="text-sm text-muted-foreground">{t('detail.loading')}</p>
) : error ? ( ) : error ? (
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>
) : tender ? ( ) : tender ? (
<dl className="space-y-4 text-sm"> <dl className="space-y-4 text-sm">
<div> <div>
<dt className={fieldLabelCls()}>Titel</dt> <dt className={fieldLabelCls()}>{t('detail.fieldTitle')}</dt>
<dd className="mt-1 font-medium text-foreground"> <dd className="mt-1 font-medium text-foreground">
{tender.title} {tender.title}
</dd> </dd>
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>Auftraggeber</dt> <dt className={fieldLabelCls()}>{t('detail.fieldBuyer')}</dt>
<dd className="mt-1">{tender.buyerName ?? '–'}</dd> <dd className="mt-1">{tender.buyerName ?? '–'}</dd>
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>Region / PLZ / Bundesland</dt> <dt className={fieldLabelCls()}>{t('detail.fieldRegion')}</dt>
<dd className="mt-1"> <dd className="mt-1">
{[tender.region, tender.plz, tender.bundesland] {[tender.region, tender.plz, tender.bundesland]
.filter(Boolean) .filter(Boolean)
@@ -177,44 +177,46 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>CPV-Codes</dt> <dt className={fieldLabelCls()}>{t('detail.fieldCpv')}</dt>
<dd className="mt-1"> <dd className="mt-1">
{tender.cpvCodes.length > 0 {tender.cpvCodes.length > 0
? tender.cpvCodes.join(', ') ? tender.cpvCodes.join(', ')
: 'keine CPV-Codes angegeben'} : t('detail.noCpv')}
</dd> </dd>
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>Abgabefrist</dt> <dt className={fieldLabelCls()}>{t('detail.fieldDeadline')}</dt>
<dd className="mt-1">{formatDeadline(tender.deadlineAt)}</dd> <dd className="mt-1">
{formatDeadline(tender.deadlineAt, t('detail.noDeadline'))}
</dd>
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>Geschätzter Auftragswert</dt> <dt className={fieldLabelCls()}>{t('detail.fieldValue')}</dt>
<dd className="mt-1">{formatValue(tender.estimatedValue)}</dd> <dd className="mt-1">
{formatValue(tender.estimatedValue, t('detail.noValue'))}
</dd>
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>Verfahrensart</dt> <dt className={fieldLabelCls()}>{t('detail.fieldProcedureType')}</dt>
<dd className="mt-1">{tender.procedureType ?? '–'}</dd> <dd className="mt-1">{tender.procedureType ?? '–'}</dd>
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>Status</dt> <dt className={fieldLabelCls()}>{t('detail.fieldStatus')}</dt>
<dd className="mt-1">{tender.status}</dd> <dd className="mt-1">{tender.status}</dd>
</div> </div>
<div> <div>
<dt className={fieldLabelCls()}>Veröffentlicht</dt> <dt className={fieldLabelCls()}>{t('detail.fieldPublished')}</dt>
<dd className="mt-1">{formatPublished(tender.publishedAt)}</dd> <dd className="mt-1">{formatPublished(tender.publishedAt)}</dd>
</div> </div>
<div className="rounded border border-border bg-muted/30 p-3"> <div className="rounded border border-border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Vergabeunterlagen werden nicht lokal gespiegelt — der {t('detail.mirrorNotice')}
folgende Link führt direkt zum Original auf dem
Quellportal.
</p> </p>
{tender.sources && tender.sources.length > 0 ? ( {tender.sources && tender.sources.length > 0 ? (
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
@@ -227,13 +229,11 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80" className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
> >
{portalLabel(source.sourcePortal)}: Zur Ausschreibung {t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
auf dem Quellportal →
</a> </a>
) : ( ) : (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{portalLabel(source.sourcePortal)}: Kein Quell-Link {t('detail.noSourceLink', { portal: portalLabel(source.sourcePortal) })}
verfügbar.
</span> </span>
)} )}
</li> </li>
@@ -246,11 +246,11 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80" className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
> >
Zur Ausschreibung auf dem Quellportal → {t('detail.sourceLinkFallback')}
</a> </a>
) : ( ) : (
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Kein Quell-Link verfügbar. {t('detail.noSourceLinkFallback')}
</p> </p>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { CoverageBanner } from './components/CoverageBanner'; import { CoverageBanner } from './components/CoverageBanner';
import { FilterPanel } from './components/FilterPanel'; import { FilterPanel } from './components/FilterPanel';
@@ -34,11 +35,11 @@ import { TenderDetail } from './components/TenderDetail';
* uses, so saving/loading a profile is congruent with the filter state the * uses, so saving/loading a profile is congruent with the filter state the
* user currently sees. * user currently sees.
* *
* Intentional hardcoded German strings: full i18n (next-intl message * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3, * tenderRadar i18n namespace — no more hardcoded German literals.
* RESOLVED).
*/ */
function TenderRadarContent() { function TenderRadarContent() {
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');
@@ -53,7 +54,7 @@ function TenderRadarContent() {
<div className="mx-auto max-w-5xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-6">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Ausschreibungs-Radar {t('page.title')}
</h1> </h1>
</div> </div>
@@ -67,15 +68,18 @@ function TenderRadarContent() {
); );
} }
function TenderRadarFallback() {
const t = useTranslations('tenderRadar');
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<p className="text-sm text-muted-foreground">{t('page.loading')}</p>
</div>
);
}
export default function TenderRadarPage() { export default function TenderRadarPage() {
return ( return (
<Suspense <Suspense fallback={<TenderRadarFallback />}>
fallback={
<div className="mx-auto max-w-5xl space-y-6 p-6">
<p className="text-sm text-muted-foreground">Lädt…</p>
</div>
}
>
<TenderRadarContent /> <TenderRadarContent />
</Suspense> </Suspense>
); );