diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx new file mode 100644 index 0000000..4e12cb1 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx @@ -0,0 +1,310 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + createSavedSearch, + deleteSavedSearch, + listSavedSearches, + updateSavedSearch, + type SavedSearch, +} from '@/lib/tender-radar-api'; + +/** + * SavedSearchBar — persönliche Suchprofile speichern/laden/umbenennen/ + * löschen (Plan 11-06, FILTER-06, D-08/D-11). + * + * IMPORTANT CONTRACT (11-06-PLAN.md key_links): the `filters` object stored + * on a profile and the URL searchParams FilterPanel/ResultsList read/write + * MUST use the identical key set — q, plz, bundesland, region, cpv + * (multi-value), deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, + * sort, favOnly. `page` and `tender` are deliberately EXCLUDED — they are + * navigation/view state (current page, open detail overlay), not filter + * state, and are not part of what a saved search represents. + * + * Serialization keeps every value as the literal string/array the URL + * carried (no type coercion) — deserialization writes those same string + * values straight back into a fresh URLSearchParams, so the round-trip is + * byte-for-byte congruent with what FilterPanel itself would produce. + * + * Per-user, NOT tenant-wide (D-11): the backend derives userId from the + * auth cookie; this component never sends a userId. + * + * Hardcoded German strings — i18n rollout is Phase 14 (Research Open + * Question 3, RESOLVED), same convention as FilterPanel/ResultsList. + */ + +const SINGLE_VALUE_FILTER_KEYS = [ + 'q', + 'plz', + 'bundesland', + 'region', + 'deadlineFrom', + 'deadlineTo', + 'openOnly', + 'valueMin', + 'valueMax', + 'includeNullValue', + 'sort', + 'favOnly', +] as const; + +const MULTI_VALUE_FILTER_KEYS = ['cpv'] as const; + +/** + * Builds the `filters` payload sent to createSavedSearch/updateSavedSearch + * from the current URL searchParams — exported for direct unit coverage. + * Only keys that are actually present in the URL are included; `page` and + * `tender` are never part of this set (see file-level doc comment). + */ +export function serializeFiltersFromSearchParams( + searchParams: URLSearchParams, +): Record { + const filters: Record = {}; + + for (const key of SINGLE_VALUE_FILTER_KEYS) { + const value = searchParams.get(key); + if (value !== null && value !== '') filters[key] = value; + } + + for (const key of MULTI_VALUE_FILTER_KEYS) { + const values = searchParams.getAll(key); + if (values.length > 0) filters[key] = values; + } + + return filters; +} + +/** + * Inverse of serializeFiltersFromSearchParams — reconstructs a + * URLSearchParams from a saved profile's `filters` JSON, writing each value + * back under its original key name (single values as-is, arrays as + * repeated entries — matching FilterPanel's own cpv-writing convention). + */ +export function filtersToSearchParams( + filters: Record, +): URLSearchParams { + const params = new URLSearchParams(); + + for (const key of SINGLE_VALUE_FILTER_KEYS) { + const value = filters[key]; + if (value !== undefined && value !== null && value !== '') { + params.set(key, String(value)); + } + } + + for (const key of MULTI_VALUE_FILTER_KEYS) { + const value = filters[key]; + if (Array.isArray(value)) { + for (const entry of value) params.append(key, String(entry)); + } + } + + return params; +} + +export function SavedSearchBar() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [profiles, setProfiles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [newName, setNewName] = useState(''); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + + const load = async () => { + setIsLoading(true); + setError(null); + try { + const data = await listSavedSearches(); + setProfiles(data); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Suchprofile konnten nicht geladen werden.', + ); + } finally { + setIsLoading(false); + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + load(); + }, []); + + const handleSave = async () => { + const name = newName.trim(); + if (!name) return; + setError(null); + try { + const filters = serializeFiltersFromSearchParams(searchParams); + await createSavedSearch({ name, filters }); + setNewName(''); + await load(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Suchprofil konnte nicht gespeichert werden.', + ); + } + }; + + const handleLoad = (profile: SavedSearch) => { + const params = filtersToSearchParams(profile.filters); + router.replace(`?${params.toString()}`); + }; + + const startRename = (profile: SavedSearch) => { + setRenamingId(profile.id); + setRenameValue(profile.name); + }; + + const cancelRename = () => { + setRenamingId(null); + setRenameValue(''); + }; + + const confirmRename = async (id: string) => { + const name = renameValue.trim(); + if (!name) return; + setError(null); + try { + await updateSavedSearch(id, { name }); + setRenamingId(null); + await load(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Suchprofil konnte nicht umbenannt werden.', + ); + } + }; + + const handleDelete = async (profile: SavedSearch) => { + setError(null); + try { + await deleteSavedSearch(profile.id); + await load(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Suchprofil konnte nicht gelöscht werden.', + ); + } + }; + + return ( +
+

Suchprofile

+ +
+
+ + setNewName(e.target.value)} + className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground" + /> +
+ +
+ + {error &&

{error}

} + + {isLoading ? ( +

Lädt…

+ ) : profiles.length === 0 ? ( +

+ Noch keine Suchprofile gespeichert. +

+ ) : ( +
    + {profiles.map((profile) => ( +
  • + {renamingId === profile.id ? ( + <> + setRenameValue(e.target.value)} + className="h-7 w-32 rounded border border-border bg-background px-2 text-xs text-foreground" + /> + + + + ) : ( + <> + + + + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx index 85721b9..bed0f3d 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { CoverageBanner } from './components/CoverageBanner'; import { FilterPanel } from './components/FilterPanel'; import { ResultsList } from './components/ResultsList'; +import { SavedSearchBar } from './components/SavedSearchBar'; import { TenderDetail } from './components/TenderDetail'; /** @@ -28,6 +29,11 @@ import { TenderDetail } from './components/TenderDetail'; * `dynamic(..., { ssr: false })` (module-loader.ts), per the plan's * explicit Next 16 guidance. * + * Plan 11-06 (FILTER-06, D-08/D-11): SavedSearchBar renders above + * FilterPanel — it reads/writes the same URL searchParams FilterPanel + * 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). @@ -52,6 +58,7 @@ function TenderRadarContent() { + diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index 04107d9..bc5cd54 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -204,3 +204,94 @@ export async function setTriage( if (!res.ok) throw new Error('Failed to save tender triage'); return res.json(); } + +/** + * A single per-user saved search profile (Plan 11-06, FILTER-06). Per-user, + * NOT tenant-wide (D-11) — the backend derives userId from the auth cookie, + * this client never sends a userId. `filters` mirrors the FilterPanel's URL + * searchParams contract exactly (q, plz, bundesland, region, cpv, + * deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly) + * — see SavedSearchBar.tsx's serialize/deserialize helpers for the + * round-trip contract. + */ +export interface SavedSearch { + id: string; + name: string; + filters: Record; + createdAt: string; + updatedAt: string; +} + +/** Payload accepted by POST /modules/tender-radar/saved-searches. */ +export interface CreateSavedSearchPayload { + name: string; + filters: Record; +} + +/** Payload accepted by PATCH /modules/tender-radar/saved-searches/:searchId. */ +export interface UpdateSavedSearchPayload { + name?: string; + filters?: Record; +} + +/** + * List this user's saved search profiles (Plan 11-06, FILTER-06). + * GET /modules/tender-radar/saved-searches + */ +export async function listSavedSearches(): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/saved-searches`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch saved searches'); + return res.json(); +} + +/** + * Create a new saved search profile from the currently active filters. + * POST /modules/tender-radar/saved-searches + */ +export async function createSavedSearch( + payload: CreateSavedSearchPayload, +): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/saved-searches`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to create saved search'); + return res.json(); +} + +/** + * Rename and/or update the filters of an existing saved search profile. + * PATCH /modules/tender-radar/saved-searches/:searchId + */ +export async function updateSavedSearch( + id: string, + payload: UpdateSavedSearchPayload, +): Promise { + const res = await fetch( + `${API_URL}/modules/tender-radar/saved-searches/${id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }, + ); + if (!res.ok) throw new Error('Failed to update saved search'); + return res.json(); +} + +/** + * Delete a saved search profile. + * DELETE /modules/tender-radar/saved-searches/:searchId + */ +export async function deleteSavedSearch(id: string): Promise { + const res = await fetch( + `${API_URL}/modules/tender-radar/saved-searches/${id}`, + { method: 'DELETE', credentials: 'include' }, + ); + if (!res.ok) throw new Error('Failed to delete saved search'); +}