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:
@@ -18,6 +18,22 @@ vi.mock('@/lib/tender-radar-api', () => ({
|
||||
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(() => {
|
||||
cleanup();
|
||||
mockFetchCoverage.mockReset();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
fetchCoverage,
|
||||
fetchDenylistedPortals,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
* public sources are ingested.
|
||||
*/
|
||||
export function CoverageBanner() {
|
||||
const t = useTranslations('tenderRadar');
|
||||
const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
|
||||
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">
|
||||
{showCoverageNote && (
|
||||
<p>
|
||||
<strong className="text-foreground">Datenabdeckung:</strong> 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.
|
||||
<strong className="text-foreground">{t('coverage.label')}</strong>{' '}
|
||||
{t('coverage.text')}
|
||||
</p>
|
||||
)}
|
||||
{denylistedPortals.length > 0 && (
|
||||
<p className={showCoverageNote ? 'mt-2' : undefined}>
|
||||
<strong className="text-foreground">Manuell beobachten:</strong>{' '}
|
||||
Diese Portale lassen laut ihren Nutzungsbedingungen keinen
|
||||
automatisierten Zugriff zu und müssen daher manuell geprüft werden:{' '}
|
||||
<strong className="text-foreground">{t('coverage.denylistLabel')}</strong>{' '}
|
||||
{t('coverage.denylistText')}{' '}
|
||||
{denylistedPortals.map((p, i) => (
|
||||
<span key={p.portal}>
|
||||
{i > 0 && ', '}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
/**
|
||||
@@ -14,14 +15,18 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
* `bundesland` (Plan 11-02), `cpv`, `valueMin`/`valueMax`/`includeNullValue`
|
||||
* (Plan 11-03) follow the same convention.
|
||||
*
|
||||
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
|
||||
* Question 3, RESOLVED).
|
||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||
* 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 }> = [
|
||||
{ value: 'published', label: 'Veröffentlichungsdatum' },
|
||||
{ value: 'deadline', label: 'Abgabefrist' },
|
||||
{ value: 'value', label: 'Auftragswert' },
|
||||
const SORT_OPTIONS: Array<{ value: string; labelKey: string }> = [
|
||||
{ value: 'published', labelKey: 'filter.sortPublished' },
|
||||
{ value: 'deadline', labelKey: 'filter.sortDeadline' },
|
||||
{ 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"
|
||||
* 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).
|
||||
* `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[] = [
|
||||
'Baden-Württemberg',
|
||||
'Bayern',
|
||||
'Berlin',
|
||||
'Brandenburg',
|
||||
'Bremen',
|
||||
'Hamburg',
|
||||
'Hessen',
|
||||
'Mecklenburg-Vorpommern',
|
||||
'Niedersachsen',
|
||||
'Nordrhein-Westfalen',
|
||||
'Rheinland-Pfalz',
|
||||
'Saarland',
|
||||
'Sachsen',
|
||||
'Sachsen-Anhalt',
|
||||
'Schleswig-Holstein',
|
||||
'Thüringen',
|
||||
const BUNDESLAND_OPTIONS: Array<{ code: string; value: string }> = [
|
||||
{ code: 'DE1', value: 'Baden-Württemberg' },
|
||||
{ code: 'DE2', value: 'Bayern' },
|
||||
{ code: 'DE3', value: 'Berlin' },
|
||||
{ code: 'DE4', value: 'Brandenburg' },
|
||||
{ code: 'DE5', value: 'Bremen' },
|
||||
{ code: 'DE6', value: 'Hamburg' },
|
||||
{ code: 'DE7', value: 'Hessen' },
|
||||
{ code: 'DE8', value: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'DE9', value: 'Niedersachsen' },
|
||||
{ code: 'DEA', value: 'Nordrhein-Westfalen' },
|
||||
{ code: 'DEB', value: 'Rheinland-Pfalz' },
|
||||
{ code: 'DEC', value: 'Saarland' },
|
||||
{ code: 'DED', value: 'Sachsen' },
|
||||
{ code: 'DEE', value: 'Sachsen-Anhalt' },
|
||||
{ code: 'DEF', value: 'Schleswig-Holstein' },
|
||||
{ code: 'DEG', value: 'Thüringen' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -55,65 +63,36 @@ const BUNDESLAND_OPTIONS: string[] = [
|
||||
* (web and api are separate packages — same "kleine Konstante spiegeln"
|
||||
* 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
|
||||
* 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 }> = [
|
||||
{ code: '03', label: 'Landwirtschaftliche, fischereiwirtschaftliche, forstwirtschaftliche und verwandte Erzeugnisse' },
|
||||
{ code: '09', label: 'Mineralölerzeugnisse, Brennstoffe, Elektrizität und andere Energiequellen' },
|
||||
{ code: '14', label: 'Bergbauerzeugnisse, Grundstoffe und verwandte Erzeugnisse' },
|
||||
{ code: '15', label: 'Nahrungsmittel, Getränke, Tabak und verwandte Erzeugnisse' },
|
||||
{ code: '16', label: 'Land- und forstwirtschaftliche Maschinen' },
|
||||
{ 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' },
|
||||
const CPV_DIVISION_CODES: string[] = [
|
||||
'03', '09', '14', '15', '16', '18', '19', '22', '24', '30',
|
||||
'31', '32', '33', '34', '35', '37', '38', '39', '41', '42',
|
||||
'43', '44', '45', '48', '50', '51', '55', '60', '63', '64',
|
||||
'65', '66', '70', '71', '72', '73', '75', '76', '77', '79',
|
||||
'80', '85', '90', '92', '98',
|
||||
];
|
||||
|
||||
function labelForCpvDivision(code: string): string {
|
||||
return CPV_DIVISION_OPTIONS.find((d) => d.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
export function FilterPanel() {
|
||||
const t = useTranslations('tenderRadar');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
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 sort = searchParams.get('sort') ?? 'published';
|
||||
// openOnly defaults to true (D-04) — only an explicit "false" turns it off.
|
||||
@@ -166,12 +145,12 @@ export function FilterPanel() {
|
||||
const cpvMatches = useMemo(() => {
|
||||
const text = cpvSearchText.trim().toLowerCase();
|
||||
if (!text) return [];
|
||||
return CPV_DIVISION_OPTIONS.filter(
|
||||
return cpvOptions.filter(
|
||||
(d) =>
|
||||
!selectedCpv.includes(d.code) &&
|
||||
(d.code.includes(text) || d.label.toLowerCase().includes(text)),
|
||||
).slice(0, 8);
|
||||
}, [cpvSearchText, selectedCpv]);
|
||||
}, [cpvSearchText, selectedCpv, cpvOptions]);
|
||||
|
||||
const inputCls =
|
||||
'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="min-w-[220px] flex-1">
|
||||
<label htmlFor="tr-filter-q" className={labelCls}>
|
||||
Suche
|
||||
{t('filter.searchLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-filter-q"
|
||||
type="text"
|
||||
placeholder="Titel oder Auftraggeber…"
|
||||
placeholder={t('filter.searchPlaceholder')}
|
||||
value={q}
|
||||
onChange={(e) => setParam('q', e.target.value)}
|
||||
className={inputCls}
|
||||
@@ -196,7 +175,7 @@ export function FilterPanel() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="tr-filter-sort" className={labelCls}>
|
||||
Sortierung
|
||||
{t('filter.sortLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="tr-filter-sort"
|
||||
@@ -206,7 +185,7 @@ export function FilterPanel() {
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -221,7 +200,7 @@ export function FilterPanel() {
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<label htmlFor="tr-filter-open-only" className="text-sm text-foreground">
|
||||
Nur noch offene
|
||||
{t('filter.openOnlyLabel')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -234,7 +213,7 @@ export function FilterPanel() {
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<label htmlFor="tr-filter-fav-only" className="text-sm text-foreground">
|
||||
Nur Favoriten/Merkliste
|
||||
{t('filter.favOnlyLabel')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +221,7 @@ export function FilterPanel() {
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label htmlFor="tr-filter-deadline-from" className={labelCls}>
|
||||
Abgabefrist von
|
||||
{t('filter.deadlineFromLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-filter-deadline-from"
|
||||
@@ -254,7 +233,7 @@ export function FilterPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tr-filter-deadline-to" className={labelCls}>
|
||||
Abgabefrist bis
|
||||
{t('filter.deadlineToLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-filter-deadline-to"
|
||||
@@ -269,14 +248,14 @@ export function FilterPanel() {
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label htmlFor="tr-filter-plz" className={labelCls}>
|
||||
PLZ
|
||||
{t('filter.plzLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-filter-plz"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={5}
|
||||
placeholder="z. B. 10"
|
||||
placeholder={t('filter.plzPlaceholder')}
|
||||
value={plz}
|
||||
onChange={(e) => setParam('plz', e.target.value)}
|
||||
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]">
|
||||
<label htmlFor="tr-filter-bundesland" className={labelCls}>
|
||||
Bundesland
|
||||
{t('filter.bundeslandLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="tr-filter-bundesland"
|
||||
@@ -293,10 +272,10 @@ export function FilterPanel() {
|
||||
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"
|
||||
>
|
||||
<option value="">Alle Bundesländer</option>
|
||||
{BUNDESLAND_OPTIONS.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
<option value="">{t('filter.bundeslandAll')}</option>
|
||||
{BUNDESLAND_OPTIONS.map((b) => (
|
||||
<option key={b.value} value={b.value}>
|
||||
{t(`filter.bundeslandOptions.${b.code}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -306,12 +285,12 @@ export function FilterPanel() {
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<div className="relative min-w-[280px] flex-1">
|
||||
<label htmlFor="tr-filter-cpv" className={labelCls}>
|
||||
Branche (CPV)
|
||||
{t('filter.cpvLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-filter-cpv"
|
||||
type="text"
|
||||
placeholder="z. B. Bau, IT, Gesundheit…"
|
||||
placeholder={t('filter.cpvPlaceholder')}
|
||||
value={cpvSearchText}
|
||||
onChange={(e) => setCpvSearchText(e.target.value)}
|
||||
className={inputCls}
|
||||
@@ -345,7 +324,7 @@ export function FilterPanel() {
|
||||
{code} – {labelForCpvDivision(code)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Division ${code} entfernen`}
|
||||
aria-label={t('filter.cpvRemoveAria', { code })}
|
||||
onClick={() => toggleCpvDivision(code)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -359,14 +338,14 @@ export function FilterPanel() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="tr-filter-value-min" className={labelCls}>
|
||||
Auftragswert von (€)
|
||||
{t('filter.valueMinLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-filter-value-min"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
placeholder="min."
|
||||
placeholder={t('filter.valueMinPlaceholder')}
|
||||
value={valueMin}
|
||||
onChange={(e) => setParam('valueMin', e.target.value)}
|
||||
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>
|
||||
<label htmlFor="tr-filter-value-max" className={labelCls}>
|
||||
Auftragswert bis (€)
|
||||
{t('filter.valueMaxLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-filter-value-max"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
placeholder="max."
|
||||
placeholder={t('filter.valueMaxPlaceholder')}
|
||||
value={valueMax}
|
||||
onChange={(e) => setParam('valueMax', e.target.value)}
|
||||
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"
|
||||
className="text-sm text-foreground"
|
||||
>
|
||||
Ohne Wertangabe einschließen
|
||||
{t('filter.includeNullValueLabel')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,37 @@ vi.mock('@/lib/tender-radar-api', () => ({
|
||||
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();
|
||||
let mockSearchParams = new URLSearchParams();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
fetchTriage,
|
||||
@@ -18,13 +19,16 @@ import {
|
||||
* sie -> listTenders()") and re-fetches whenever the URL changes.
|
||||
*
|
||||
* 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 }> = [
|
||||
{ key: 'deadline', label: 'Frist' },
|
||||
{ key: 'value', label: 'Wert' },
|
||||
{ key: 'published', label: 'Veröffentlicht' },
|
||||
const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; labelKey: string }> = [
|
||||
{ key: 'deadline', labelKey: 'results.colDeadline' },
|
||||
{ key: 'value', labelKey: 'results.colValue' },
|
||||
{ key: 'published', labelKey: 'results.colPublished' },
|
||||
];
|
||||
|
||||
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.
|
||||
* Renders "keine Wertangabe" for null/unparseable values — never blank,
|
||||
* never 0, and never a thrown error.
|
||||
* Renders the translated "no value stated" label for null/unparseable
|
||||
* values — never blank, never 0, and never a thrown error.
|
||||
*/
|
||||
function formatValue(value: string | null): string {
|
||||
if (value === null || value === undefined) return 'keine Wertangabe';
|
||||
function formatValue(value: string | null, noValueLabel: string): string {
|
||||
if (value === null || value === undefined) return noValueLabel;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 'keine Wertangabe';
|
||||
if (!Number.isFinite(n)) return noValueLabel;
|
||||
return n.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
@@ -59,6 +63,7 @@ function formatValue(value: string | null): string {
|
||||
}
|
||||
|
||||
export function ResultsList() {
|
||||
const t = useTranslations('tenderRadar');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const paramsKey = searchParams.toString();
|
||||
@@ -90,7 +95,7 @@ export function ResultsList() {
|
||||
// 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);
|
||||
const ids = data.items.map((item) => item.id);
|
||||
try {
|
||||
const rows = await fetchTriage(ids);
|
||||
if (Array.isArray(rows)) {
|
||||
@@ -110,13 +115,12 @@ export function ResultsList() {
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Ausschreibungen konnten nicht geladen werden.',
|
||||
err instanceof Error ? err.message : t('results.error'),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paramsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -198,8 +202,8 @@ export function ResultsList() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th className={thStaticCls}>Titel</th>
|
||||
<th className={thStaticCls}>Auftraggeber</th>
|
||||
<th className={thStaticCls}>{t('results.colTitle')}</th>
|
||||
<th className={thStaticCls}>{t('results.colBuyer')}</th>
|
||||
{SORT_HEADERS.map((h) => (
|
||||
<th
|
||||
key={h.key}
|
||||
@@ -207,11 +211,11 @@ export function ResultsList() {
|
||||
onClick={() => handleSort(h.key)}
|
||||
aria-sort={currentSort === h.key ? 'ascending' : 'none'}
|
||||
>
|
||||
{h.label}
|
||||
{t(h.labelKey)}
|
||||
{currentSort === h.key ? ' ▾' : ''}
|
||||
</th>
|
||||
))}
|
||||
<th className={thStaticCls}>Triage</th>
|
||||
<th className={thStaticCls}>{t('results.colTriage')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -235,11 +239,10 @@ export function ResultsList() {
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-10 text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
Keine Ausschreibungen gefunden.
|
||||
{t('results.emptyTitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Passen Sie die Filter an oder deaktivieren Sie „nur noch
|
||||
offene".
|
||||
{t('results.emptyBody')}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -262,7 +265,9 @@ export function ResultsList() {
|
||||
</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">
|
||||
{formatValue(tender.estimatedValue, t('results.noValue'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -272,17 +277,17 @@ export function ResultsList() {
|
||||
aria-pressed={rowTriage.isRead}
|
||||
aria-label={
|
||||
rowTriage.isRead
|
||||
? 'Als ungelesen markieren'
|
||||
: 'Als gelesen markieren'
|
||||
? t('results.markUnread')
|
||||
: t('results.markRead')
|
||||
}
|
||||
title={
|
||||
rowTriage.isRead
|
||||
? 'Als ungelesen markieren'
|
||||
: 'Als gelesen markieren'
|
||||
? t('results.markUnread')
|
||||
: t('results.markRead')
|
||||
}
|
||||
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
|
||||
type="button"
|
||||
@@ -290,13 +295,13 @@ export function ResultsList() {
|
||||
aria-pressed={rowTriage.isFavorite}
|
||||
aria-label={
|
||||
rowTriage.isFavorite
|
||||
? 'Favorit entfernen'
|
||||
: 'Favorit setzen'
|
||||
? t('results.removeFavorite')
|
||||
: t('results.setFavorite')
|
||||
}
|
||||
title={
|
||||
rowTriage.isFavorite
|
||||
? 'Favorit entfernen'
|
||||
: 'Favorit setzen'
|
||||
? t('results.removeFavorite')
|
||||
: t('results.setFavorite')
|
||||
}
|
||||
className={`rounded border px-2 py-1 text-xs transition-colors hover:bg-muted ${
|
||||
rowTriage.isFavorite
|
||||
@@ -304,7 +309,7 @@ export function ResultsList() {
|
||||
: 'border-border text-foreground'
|
||||
}`}
|
||||
>
|
||||
{rowTriage.isFavorite ? '★ Favorit' : '☆ Favorit'}
|
||||
{rowTriage.isFavorite ? t('results.favoriteOn') : t('results.favoriteOff')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -318,8 +323,11 @@ export function ResultsList() {
|
||||
{!isLoading && total > limit && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Zeige {(page - 1) * limit + 1}–{Math.min(page * limit, total)} von{' '}
|
||||
{total}
|
||||
{t('results.showingRange', {
|
||||
from: String((page - 1) * limit + 1),
|
||||
to: String(Math.min(page * limit, total)),
|
||||
total: String(total),
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -328,7 +336,7 @@ export function ResultsList() {
|
||||
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"
|
||||
>
|
||||
← Zurück
|
||||
{t('results.prevPage')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -336,7 +344,7 @@ export function ResultsList() {
|
||||
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"
|
||||
>
|
||||
Weiter →
|
||||
{t('results.nextPage')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,45 @@ vi.mock('@/lib/tender-radar-api', () => ({
|
||||
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();
|
||||
let mockSearchParams = new URLSearchParams();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
createSavedSearch,
|
||||
@@ -36,8 +37,8 @@ import {
|
||||
* a profile field alongside name/filters, not a filter key — it never
|
||||
* touches the URL searchParams filters contract above.
|
||||
*
|
||||
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
|
||||
* Question 3, RESOLVED), same convention as FilterPanel/ResultsList.
|
||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||
* tenderRadar i18n namespace.
|
||||
*/
|
||||
|
||||
const SINGLE_VALUE_FILTER_KEYS = [
|
||||
@@ -110,6 +111,7 @@ export function filtersToSearchParams(
|
||||
}
|
||||
|
||||
export function SavedSearchBar() {
|
||||
const t = useTranslations('tenderRadar');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -128,9 +130,7 @@ export function SavedSearchBar() {
|
||||
setProfiles(data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Suchprofile konnten nicht geladen werden.',
|
||||
err instanceof Error ? err.message : t('savedSearch.errorLoad'),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -153,9 +153,7 @@ export function SavedSearchBar() {
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Suchprofil konnte nicht gespeichert werden.',
|
||||
err instanceof Error ? err.message : t('savedSearch.errorSave'),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -185,9 +183,7 @@ export function SavedSearchBar() {
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Suchprofil konnte nicht umbenannt werden.',
|
||||
err instanceof Error ? err.message : t('savedSearch.errorRename'),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -199,9 +195,7 @@ export function SavedSearchBar() {
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Suchprofil konnte nicht gelöscht werden.',
|
||||
err instanceof Error ? err.message : t('savedSearch.errorDelete'),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -213,16 +207,14 @@ export function SavedSearchBar() {
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Sofort-Alert konnte nicht umgeschaltet werden.',
|
||||
err instanceof Error ? err.message : t('savedSearch.errorToggleAlert'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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="min-w-[220px] flex-1">
|
||||
@@ -230,12 +222,12 @@ export function SavedSearchBar() {
|
||||
htmlFor="tr-saved-search-name"
|
||||
className="mb-1 block text-sm text-foreground"
|
||||
>
|
||||
Aktuelle Filter speichern als
|
||||
{t('savedSearch.saveAsLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tr-saved-search-name"
|
||||
type="text"
|
||||
placeholder="Name des Suchprofils…"
|
||||
placeholder={t('savedSearch.namePlaceholder')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
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()}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{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 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch keine Suchprofile gespeichert.
|
||||
{t('savedSearch.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
@@ -276,19 +268,19 @@ export function SavedSearchBar() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${profile.name} umbenennen bestätigen`}
|
||||
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
|
||||
onClick={() => confirmRename(profile.id)}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Speichern
|
||||
{t('savedSearch.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Umbenennen abbrechen"
|
||||
aria-label={t('savedSearch.renameCancelAria')}
|
||||
onClick={cancelRename}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Abbrechen
|
||||
{t('savedSearch.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -296,41 +288,41 @@ export function SavedSearchBar() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLoad(profile)}
|
||||
title="Suchprofil laden"
|
||||
title={t('savedSearch.loadTitle')}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{profile.name}
|
||||
</button>
|
||||
<label
|
||||
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
|
||||
type="checkbox"
|
||||
aria-label={`Sofort-Alert für ${profile.name}`}
|
||||
aria-label={t('savedSearch.instantAlertAria', { name: profile.name })}
|
||||
checked={profile.instantAlert}
|
||||
onChange={() => handleToggleInstantAlert(profile)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
Sofort-Alert
|
||||
{t('savedSearch.instantAlertLabel')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${profile.name} umbenennen`}
|
||||
title="Umbenennen"
|
||||
aria-label={t('savedSearch.renameAria', { name: profile.name })}
|
||||
title={t('savedSearch.renameTitle')}
|
||||
onClick={() => startRename(profile)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Umbenennen
|
||||
{t('savedSearch.renameTitle')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${profile.name} löschen`}
|
||||
title="Löschen"
|
||||
aria-label={t('savedSearch.deleteAria', { name: profile.name })}
|
||||
title={t('savedSearch.deleteTitle')}
|
||||
onClick={() => handleDelete(profile)}
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Löschen
|
||||
{t('savedSearch.deleteTitle')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,46 @@ vi.mock('@/lib/tender-radar-api', () => ({
|
||||
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(() => {
|
||||
cleanup();
|
||||
mockGetTender.mockReset();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
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
|
||||
* oder leer ist (Alt-Tender / API-Antworten ohne include).
|
||||
*
|
||||
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
|
||||
* Question 3, RESOLVED), same convention as ResultsList/FilterPanel.
|
||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||
* tenderRadar i18n namespace.
|
||||
*/
|
||||
|
||||
interface TenderDetailProps {
|
||||
@@ -36,22 +37,22 @@ interface TenderDetailProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** D-05 gilt auch für die Detailansicht: NIE ausblenden, NIE Fehler werfen. */
|
||||
function formatDeadline(value: string | null): string {
|
||||
if (!value) return 'keine Frist angegeben';
|
||||
/** D-05 applies to the detail view too: NEVER hide, NEVER throw. */
|
||||
function formatDeadline(value: string | null, noDeadlineLabel: string): string {
|
||||
if (!value) return noDeadlineLabel;
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return 'keine Frist angegeben';
|
||||
if (isNaN(d.getTime())) return noDeadlineLabel;
|
||||
return d.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
/**
|
||||
* `estimatedValue` kommt als String (Prisma Decimal) oder null — niemals
|
||||
* blind `Number()`-koerzieren (gleiche Regel wie ResultsList.formatValue).
|
||||
* `estimatedValue` arrives as a string (Prisma Decimal) or null — never
|
||||
* blindly coerce with `Number()` (same rule as ResultsList.formatValue).
|
||||
*/
|
||||
function formatValue(value: string | null): string {
|
||||
if (value === null || value === undefined) return 'keine Wertangabe';
|
||||
function formatValue(value: string | null, noValueLabel: string): string {
|
||||
if (value === null || value === undefined) return noValueLabel;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 'keine Wertangabe';
|
||||
if (!Number.isFinite(n)) return noValueLabel;
|
||||
return n.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
@@ -87,6 +88,7 @@ function portalLabel(sourcePortal: string): string {
|
||||
}
|
||||
|
||||
export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
const t = useTranslations('tenderRadar');
|
||||
const [tender, setTender] = useState<Tender | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -104,9 +106,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Ausschreibung konnte nicht geladen werden.',
|
||||
err instanceof Error ? err.message : t('detail.error'),
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -124,12 +124,12 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
className="fixed inset-0 z-50 flex justify-end bg-black/40"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Ausschreibungsdetails"
|
||||
aria-label={t('detail.dialogAria')}
|
||||
>
|
||||
{/* Backdrop click closes the drawer, mirroring standard overlay UX */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Schließen"
|
||||
aria-label={t('detail.close')}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 h-full w-full cursor-default"
|
||||
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="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold tracking-tight">
|
||||
Ausschreibungsdetails
|
||||
{t('detail.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
Schließen
|
||||
{t('detail.close')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Lädt…</p>
|
||||
<p className="text-sm text-muted-foreground">{t('detail.loading')}</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : tender ? (
|
||||
<dl className="space-y-4 text-sm">
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Titel</dt>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldTitle')}</dt>
|
||||
<dd className="mt-1 font-medium text-foreground">
|
||||
{tender.title}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Auftraggeber</dt>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldBuyer')}</dt>
|
||||
<dd className="mt-1">{tender.buyerName ?? '–'}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Region / PLZ / Bundesland</dt>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldRegion')}</dt>
|
||||
<dd className="mt-1">
|
||||
{[tender.region, tender.plz, tender.bundesland]
|
||||
.filter(Boolean)
|
||||
@@ -177,44 +177,46 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>CPV-Codes</dt>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldCpv')}</dt>
|
||||
<dd className="mt-1">
|
||||
{tender.cpvCodes.length > 0
|
||||
? tender.cpvCodes.join(', ')
|
||||
: 'keine CPV-Codes angegeben'}
|
||||
: t('detail.noCpv')}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Abgabefrist</dt>
|
||||
<dd className="mt-1">{formatDeadline(tender.deadlineAt)}</dd>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldDeadline')}</dt>
|
||||
<dd className="mt-1">
|
||||
{formatDeadline(tender.deadlineAt, t('detail.noDeadline'))}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Geschätzter Auftragswert</dt>
|
||||
<dd className="mt-1">{formatValue(tender.estimatedValue)}</dd>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldValue')}</dt>
|
||||
<dd className="mt-1">
|
||||
{formatValue(tender.estimatedValue, t('detail.noValue'))}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Verfahrensart</dt>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldProcedureType')}</dt>
|
||||
<dd className="mt-1">{tender.procedureType ?? '–'}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Status</dt>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldStatus')}</dt>
|
||||
<dd className="mt-1">{tender.status}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className={fieldLabelCls()}>Veröffentlicht</dt>
|
||||
<dt className={fieldLabelCls()}>{t('detail.fieldPublished')}</dt>
|
||||
<dd className="mt-1">{formatPublished(tender.publishedAt)}</dd>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-border bg-muted/30 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vergabeunterlagen werden nicht lokal gespiegelt — der
|
||||
folgende Link führt direkt zum Original auf dem
|
||||
Quellportal.
|
||||
{t('detail.mirrorNotice')}
|
||||
</p>
|
||||
{tender.sources && tender.sources.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1">
|
||||
@@ -227,13 +229,11 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
>
|
||||
{portalLabel(source.sourcePortal)}: Zur Ausschreibung
|
||||
auf dem Quellportal →
|
||||
{t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{portalLabel(source.sourcePortal)}: Kein Quell-Link
|
||||
verfügbar.
|
||||
{t('detail.noSourceLink', { portal: portalLabel(source.sourcePortal) })}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
@@ -246,11 +246,11 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Kein Quell-Link verfügbar.
|
||||
{t('detail.noSourceLinkFallback')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CoverageBanner } from './components/CoverageBanner';
|
||||
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
|
||||
* user currently sees.
|
||||
*
|
||||
* Intentional hardcoded German strings: full i18n (next-intl message
|
||||
* keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3,
|
||||
* RESOLVED).
|
||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||
* tenderRadar i18n namespace — no more hardcoded German literals.
|
||||
*/
|
||||
function TenderRadarContent() {
|
||||
const t = useTranslations('tenderRadar');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tenderId = searchParams.get('tender');
|
||||
@@ -53,7 +54,7 @@ function TenderRadarContent() {
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Ausschreibungs-Radar
|
||||
{t('page.title')}
|
||||
</h1>
|
||||
</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() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<p className="text-sm text-muted-foreground">Lädt…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<TenderRadarFallback />}>
|
||||
<TenderRadarContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user