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:
@@ -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 />
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user