diff --git a/backend/alembic/versions/009_filter_presets.py b/backend/alembic/versions/009_filter_presets.py new file mode 100644 index 0000000..ceafc07 --- /dev/null +++ b/backend/alembic/versions/009_filter_presets.py @@ -0,0 +1,40 @@ +"""Add filter_presets table. + +Revision ID: 009_filter_presets +Revises: 008_password_reset_tokens +""" + +from alembic import op +import sqlalchemy as sa + +revision = "009_filter_presets" +down_revision = "008_password_reset_tokens" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "filter_presets", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("filters", sa.String(1000), nullable=False), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index("idx_fp_user", "filter_presets", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("idx_fp_user", table_name="filter_presets") + op.drop_table("filter_presets") diff --git a/backend/app/api/filter_presets.py b/backend/app/api/filter_presets.py new file mode 100644 index 0000000..e9f1f16 --- /dev/null +++ b/backend/app/api/filter_presets.py @@ -0,0 +1,95 @@ +"""Filter presets API — save/load/delete user-specific filter combinations.""" + +import json + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.dependencies import get_current_user +from app.database import get_db +from app.models.user import FilterPreset, User +from app.schemas.filter_preset import FilterPresetCreate, FilterPresetResponse, FilterValues + +router = APIRouter() + +MAX_PRESETS_PER_USER = 10 + + +@router.get("/", response_model=list[FilterPresetResponse]) +def list_presets( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Return all filter presets for the current user.""" + presets = ( + db.query(FilterPreset) + .filter(FilterPreset.user_id == user.id) + .order_by(FilterPreset.created_at.desc()) + .all() + ) + result = [] + for p in presets: + filters = json.loads(p.filters) if isinstance(p.filters, str) else p.filters + result.append(FilterPresetResponse( + id=p.id, + name=p.name, + filters=FilterValues(**filters), + created_at=p.created_at, + )) + return result + + +@router.post("/", response_model=FilterPresetResponse, status_code=status.HTTP_201_CREATED) +def create_preset( + data: FilterPresetCreate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Create a new filter preset for the current user.""" + count = db.query(FilterPreset).filter(FilterPreset.user_id == user.id).count() + if count >= MAX_PRESETS_PER_USER: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximal {MAX_PRESETS_PER_USER} gespeicherte Filter erlaubt.", + ) + + if not data.name.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Name darf nicht leer sein.", + ) + + preset = FilterPreset( + user_id=user.id, + name=data.name.strip()[:100], + filters=json.dumps(data.filters.model_dump(exclude_none=True)), + ) + db.add(preset) + db.commit() + db.refresh(preset) + + filters = json.loads(preset.filters) + return FilterPresetResponse( + id=preset.id, + name=preset.name, + filters=FilterValues(**filters), + created_at=preset.created_at, + ) + + +@router.delete("/{preset_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_preset( + preset_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Delete a filter preset (own presets only).""" + preset = ( + db.query(FilterPreset) + .filter(FilterPreset.id == preset_id, FilterPreset.user_id == user.id) + .first() + ) + if not preset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preset nicht gefunden.") + db.delete(preset) + db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 471c8b2..af536b5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app.api.cases import router as cases_router from app.api.coding import router as coding_router from app.api.import_router import router as import_router from app.api.notifications import router as notifications_router +from app.api.filter_presets import router as filter_presets_router from app.api.reports import router as reports_router from app.config import get_settings @@ -35,6 +36,7 @@ app.include_router(cases_router, prefix="/api/cases", tags=["cases"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["notifications"]) app.include_router(coding_router, prefix="/api/coding", tags=["coding"]) app.include_router(reports_router, prefix="/api/reports", tags=["reports"]) +app.include_router(filter_presets_router, prefix="/api/filter-presets", tags=["filter-presets"]) @app.get("/api/health") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index dbd4a1c..5aa13fd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -170,6 +170,28 @@ class PasswordResetToken(Base): ) +class FilterPreset(Base): + __tablename__ = "filter_presets" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + filters: Mapped[str] = mapped_column(String(1000), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + user: Mapped[User] = relationship() + + __table_args__ = ( + Index("idx_fp_user", "user_id"), + ) + + class AllowedDomain(Base): __tablename__ = "allowed_domains" diff --git a/backend/app/schemas/filter_preset.py b/backend/app/schemas/filter_preset.py new file mode 100644 index 0000000..1de2a8d --- /dev/null +++ b/backend/app/schemas/filter_preset.py @@ -0,0 +1,33 @@ +"""Pydantic schemas for filter presets.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class FilterValues(BaseModel): + """The actual filter values stored in a preset.""" + + jahr: Optional[int] = None + fallgruppe: Optional[str] = None + has_icd: Optional[str] = None + search: Optional[str] = None + + +class FilterPresetCreate(BaseModel): + """Request body for creating a filter preset.""" + + name: str + filters: FilterValues + + +class FilterPresetResponse(BaseModel): + """Response for a filter preset.""" + + id: int + name: str + filters: FilterValues + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/docs/plans/2026-02-28-filter-presets-design.md b/docs/plans/2026-02-28-filter-presets-design.md new file mode 100644 index 0000000..78aa6d0 --- /dev/null +++ b/docs/plans/2026-02-28-filter-presets-design.md @@ -0,0 +1,44 @@ +# Erweiterte Suche mit Filterspeicherung — Design + +## Ziel + +Nutzer können häufig genutzte Filter-Kombinationen als Presets speichern und per Klick wiederherstellen. + +## Backend + +### Datenmodell: `filter_presets` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| id | int PK | Auto-increment | +| user_id | int FK | users.id | +| name | varchar(100) | Anzeigename | +| filters | JSON | `{jahr, fallgruppe, has_icd, search}` | +| created_at | datetime | server_default=now() | + +Max 10 Presets pro User (enforced im Endpoint). + +### Endpoints + +- `GET /filter-presets` — Alle Presets des Users +- `POST /filter-presets` — Neues Preset (Name + Filter-JSON) +- `DELETE /filter-presets/{id}` — Preset löschen (nur eigene) + +## Frontend + +Dropdown-Button "Gespeicherte Filter" neben dem Download-Button auf der CasesPage. +- Klick auf Preset → Filter anwenden +- "Aktuellen Filter speichern" → Name-Dialog +- X-Icon zum Löschen + +## Dateien + +| Datei | Änderung | +|-------|----------| +| `backend/app/models/user.py` | `FilterPreset` Modell | +| `backend/alembic/versions/009_filter_presets.py` | Migration | +| `backend/app/schemas/filter_preset.py` | Pydantic Schemas | +| `backend/app/api/filter_presets.py` | Neue API-Datei | +| `backend/app/main.py` | Router registrieren | +| `frontend/src/hooks/useFilterPresets.ts` | TanStack Query Hook | +| `frontend/src/pages/CasesPage.tsx` | Preset-Dropdown | diff --git a/docs/todo.md b/docs/todo.md index 598f70a..1c11351 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,6 +15,6 @@ ## Niedrige Priorität -- [ ] **Erweiterte Suche mit Filterspeicherung** — Häufig genutzte Filter als Presets speichern (z.B. "Onko ohne ICD 2026"). +- [x] **Erweiterte Suche mit Filterspeicherung** — ✅ Implementiert: Filter-Presets pro User in DB gespeichert (max 10). Popover-Dropdown mit Stern-Icon auf CasesPage. Klick lädt Filter, X löscht Preset, "+" speichert aktuellen Filter. - [ ] **Dashboard: Durchlaufzeiten** — Durchschnittliche Dauer von Fallerfassung bis Gutachten visualisieren. - [x] **Passwort-Reset per E-Mail** — ✅ Implementiert: "Passwort vergessen?" auf Login-Seite, Reset-Link per E-Mail (1h gültig), ResetPasswordPage mit Passwort-Formular. Rate-Limiting (3/h), Audit-Log, Token als SHA-256 Hash gespeichert. diff --git a/frontend/src/hooks/useFilterPresets.ts b/frontend/src/hooks/useFilterPresets.ts new file mode 100644 index 0000000..13c67eb --- /dev/null +++ b/frontend/src/hooks/useFilterPresets.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import api from '@/services/api' + +export interface FilterValues { + jahr?: number + fallgruppe?: string + has_icd?: string + search?: string +} + +export interface FilterPreset { + id: number + name: string + filters: FilterValues + created_at: string +} + +export function useFilterPresets() { + return useQuery({ + queryKey: ['filter-presets'], + queryFn: () => api.get('/filter-presets/').then(r => r.data), + }) +} + +export function useCreateFilterPreset() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: { name: string; filters: FilterValues }) => + api.post('/filter-presets/', data).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['filter-presets'] }), + }) +} + +export function useDeleteFilterPreset() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => api.delete(`/filter-presets/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['filter-presets'] }), + }) +} diff --git a/frontend/src/pages/CasesPage.tsx b/frontend/src/pages/CasesPage.tsx index 91672d1..583e135 100644 --- a/frontend/src/pages/CasesPage.tsx +++ b/frontend/src/pages/CasesPage.tsx @@ -1,12 +1,13 @@ import { useState, useEffect, useCallback, useRef } from 'react' import type { KeyboardEvent } from 'react' import { - Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, Pencil, X, Info, Loader2, Download, + Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, Pencil, X, Info, Loader2, Download, Star, Trash2, } from 'lucide-react' import type { Case } from '@/types' import { useAuth } from '@/context/AuthContext' import { useCases, usePendingIcdCases, useIcdUpdate, type CaseFilters } from '@/hooks/useCases' import api from '@/services/api' +import { useFilterPresets, useCreateFilterPreset, useDeleteFilterPreset } from '@/hooks/useFilterPresets' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -22,6 +23,9 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' +import { + Popover, PopoverContent, PopoverTrigger, +} from '@/components/ui/popover' import { Alert, AlertDescription } from '@/components/ui/alert' import { Tooltip, TooltipContent, TooltipTrigger, @@ -73,6 +77,12 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) { const [sheetOpen, setSheetOpen] = useState(false) const [batchMode, setBatchMode] = useState(false) const [exporting, setExporting] = useState(false) + const [presetOpen, setPresetOpen] = useState(false) + const [presetName, setPresetName] = useState('') + const [showSavePreset, setShowSavePreset] = useState(false) + const { data: presets } = useFilterPresets() + const createPreset = useCreateFilterPreset() + const deletePreset = useDeleteFilterPreset() // Debounce search const debounceRef = useRef | null>(null) @@ -142,6 +152,28 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) { } } + const applyPreset = (filters: { jahr?: number; fallgruppe?: string; has_icd?: string; search?: string }) => { + setJahr(filters.jahr != null ? String(filters.jahr) : '__all__') + setFallgruppe(filters.fallgruppe || '__all__') + setHasIcd(filters.has_icd || '__all__') + setSearch(filters.search || '') + setDebouncedSearch(filters.search || '') + setPage(1) + setPresetOpen(false) + } + + const savePreset = async () => { + if (!presetName.trim()) return + const filterValues: Record = {} + if (jahr !== '__all__') filterValues.jahr = Number(jahr) + if (fallgruppe !== '__all__') filterValues.fallgruppe = fallgruppe + if (hasIcd !== '__all__') filterValues.has_icd = hasIcd + if (debouncedSearch) filterValues.search = debouncedSearch + await createPreset.mutateAsync({ name: presetName.trim(), filters: filterValues }) + setPresetName('') + setShowSavePreset(false) + } + return (
@@ -216,6 +248,67 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) { Gefilterte Fallliste als Excel exportieren + + + + + +
+ {presets && presets.length > 0 ? ( + presets.map((p) => ( +
+ + +
+ )) + ) : ( +

Keine gespeicherten Filter.

+ )} +
+ {showSavePreset ? ( +
+ setPresetName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); savePreset() } if (e.key === 'Escape') setShowSavePreset(false) }} + placeholder="z.B. Onko ohne ICD" + className="h-8 text-sm" + autoFocus + /> + +
+ ) : ( + + )} +
+
+
+
)}