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 { CoverageBanner } from './components/CoverageBanner';
|
||||||
import { FilterPanel } from './components/FilterPanel';
|
import { FilterPanel } from './components/FilterPanel';
|
||||||
import { ResultsList } from './components/ResultsList';
|
import { ResultsList } from './components/ResultsList';
|
||||||
|
import { SavedSearchBar } from './components/SavedSearchBar';
|
||||||
import { TenderDetail } from './components/TenderDetail';
|
import { TenderDetail } from './components/TenderDetail';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +29,11 @@ import { TenderDetail } from './components/TenderDetail';
|
|||||||
* `dynamic(..., { ssr: false })` (module-loader.ts), per the plan's
|
* `dynamic(..., { ssr: false })` (module-loader.ts), per the plan's
|
||||||
* explicit Next 16 guidance.
|
* 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
|
* Intentional hardcoded German strings: full i18n (next-intl message
|
||||||
* keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3,
|
* keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3,
|
||||||
* RESOLVED).
|
* RESOLVED).
|
||||||
@@ -52,6 +58,7 @@ function TenderRadarContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CoverageBanner />
|
<CoverageBanner />
|
||||||
|
<SavedSearchBar />
|
||||||
<FilterPanel />
|
<FilterPanel />
|
||||||
<ResultsList />
|
<ResultsList />
|
||||||
|
|
||||||
|
|||||||
@@ -204,3 +204,94 @@ export async function setTriage(
|
|||||||
if (!res.ok) throw new Error('Failed to save tender triage');
|
if (!res.ok) throw new Error('Failed to save tender triage');
|
||||||
return res.json();
|
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