feat(11-06): SavedSearchBar UI — save/load/rename/delete profiles (FILTER-06)

GREEN phase — extends tender-radar-api.ts with listSavedSearches/
createSavedSearch/updateSavedSearch/deleteSavedSearch (plain fetch,
credentials: include), adds SavedSearchBar with the
serializeFiltersFromSearchParams/filtersToSearchParams round-trip helpers
(URL searchParams <-> filters JSON, deliberately excluding page/tender —
navigation state, not filter state), and mounts it above FilterPanel in
page.tsx. Hardcoded German UI per phase convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:50:55 +02:00
parent ca843ab134
commit dd3ff9ea30
3 changed files with 408 additions and 0 deletions
@@ -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<string, unknown> {
const filters: Record<string, unknown> = {};
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<string, unknown>,
): 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<SavedSearch[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [renamingId, setRenamingId] = useState<string | null>(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 (
<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>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[220px] flex-1">
<label
htmlFor="tr-saved-search-name"
className="mb-1 block text-sm text-foreground"
>
Aktuelle Filter speichern als
</label>
<input
id="tr-saved-search-name"
type="text"
placeholder="Name des Suchprofils…"
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"
/>
</div>
<button
type="button"
onClick={handleSave}
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
</button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{isLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
) : profiles.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Suchprofile gespeichert.
</p>
) : (
<ul className="flex flex-wrap gap-2">
{profiles.map((profile) => (
<li
key={profile.id}
className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 text-sm text-foreground"
>
{renamingId === profile.id ? (
<>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="h-7 w-32 rounded border border-border bg-background px-2 text-xs text-foreground"
/>
<button
type="button"
aria-label={`${profile.name} umbenennen bestätigen`}
onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline"
>
Speichern
</button>
<button
type="button"
aria-label="Umbenennen abbrechen"
onClick={cancelRename}
className="text-xs text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
</>
) : (
<>
<button
type="button"
onClick={() => handleLoad(profile)}
title="Suchprofil laden"
className="font-medium hover:underline"
>
{profile.name}
</button>
<button
type="button"
aria-label={`${profile.name} umbenennen`}
title="Umbenennen"
onClick={() => startRename(profile)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Umbenennen
</button>
<button
type="button"
aria-label={`${profile.name} löschen`}
title="Löschen"
onClick={() => handleDelete(profile)}
className="text-xs text-muted-foreground hover:text-destructive"
>
Löschen
</button>
</>
)}
</li>
))}
</ul>
)}
</div>
);
}
@@ -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() {
</div>
<CoverageBanner />
<SavedSearchBar />
<FilterPanel />
<ResultsList />
+91
View File
@@ -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<string, unknown>;
createdAt: string;
updatedAt: string;
}
/** Payload accepted by POST /modules/tender-radar/saved-searches. */
export interface CreateSavedSearchPayload {
name: string;
filters: Record<string, unknown>;
}
/** Payload accepted by PATCH /modules/tender-radar/saved-searches/:searchId. */
export interface UpdateSavedSearchPayload {
name?: string;
filters?: Record<string, unknown>;
}
/**
* List this user's saved search profiles (Plan 11-06, FILTER-06).
* GET /modules/tender-radar/saved-searches
*/
export async function listSavedSearches(): Promise<SavedSearch[]> {
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<SavedSearch> {
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<SavedSearch> {
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<void> {
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');
}