feat: add saved filter presets for case list

Users can save frequently used filter combinations (year, fallgruppe,
ICD status, search) as named presets. Stored server-side in new
filter_presets table (max 10 per user). Star-icon Popover on CasesPage
to load, save, or delete presets. TanStack Query hooks for CRUD.

New files: FilterPreset model, migration 009, API router, schemas,
useFilterPresets hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-28 15:13:20 +00:00
parent d5db84d93f
commit f3846813a4
9 changed files with 371 additions and 2 deletions

View file

@ -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")

View file

@ -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()

View file

@ -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.coding import router as coding_router
from app.api.import_router import router as import_router from app.api.import_router import router as import_router
from app.api.notifications import router as notifications_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.api.reports import router as reports_router
from app.config import get_settings 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(notifications_router, prefix="/api/notifications", tags=["notifications"])
app.include_router(coding_router, prefix="/api/coding", tags=["coding"]) app.include_router(coding_router, prefix="/api/coding", tags=["coding"])
app.include_router(reports_router, prefix="/api/reports", tags=["reports"]) 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") @app.get("/api/health")

View file

@ -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): class AllowedDomain(Base):
__tablename__ = "allowed_domains" __tablename__ = "allowed_domains"

View file

@ -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}

View file

@ -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 |

View file

@ -15,6 +15,6 @@
## Niedrige Priorität ## 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. - [ ] **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. - [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.

View file

@ -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<FilterPreset[]>('/filter-presets/').then(r => r.data),
})
}
export function useCreateFilterPreset() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: { name: string; filters: FilterValues }) =>
api.post<FilterPreset>('/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'] }),
})
}

View file

@ -1,12 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import type { KeyboardEvent } from 'react' import type { KeyboardEvent } from 'react'
import { 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' } from 'lucide-react'
import type { Case } from '@/types' import type { Case } from '@/types'
import { useAuth } from '@/context/AuthContext' import { useAuth } from '@/context/AuthContext'
import { useCases, usePendingIcdCases, useIcdUpdate, type CaseFilters } from '@/hooks/useCases' import { useCases, usePendingIcdCases, useIcdUpdate, type CaseFilters } from '@/hooks/useCases'
import api from '@/services/api' import api from '@/services/api'
import { useFilterPresets, useCreateFilterPreset, useDeleteFilterPreset } from '@/hooks/useFilterPresets'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -22,6 +23,9 @@ import {
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import {
Popover, PopoverContent, PopoverTrigger,
} from '@/components/ui/popover'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { import {
Tooltip, TooltipContent, TooltipTrigger, Tooltip, TooltipContent, TooltipTrigger,
@ -73,6 +77,12 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [batchMode, setBatchMode] = useState(false) const [batchMode, setBatchMode] = useState(false)
const [exporting, setExporting] = 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 // Debounce search
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<string, string | number> = {}
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 ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -216,6 +248,67 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Gefilterte Fallliste als Excel exportieren</TooltipContent> <TooltipContent>Gefilterte Fallliste als Excel exportieren</TooltipContent>
</Tooltip> </Tooltip>
<Popover open={presetOpen} onOpenChange={setPresetOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Star className="size-4" />
Filter
{presets && presets.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs">{presets.length}</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="end">
<div className="space-y-1">
{presets && presets.length > 0 ? (
presets.map((p) => (
<div key={p.id} className="flex items-center gap-1 group">
<button
className="flex-1 text-left text-sm px-2 py-1.5 rounded hover:bg-accent transition-colors truncate"
onClick={() => applyPreset(p.filters)}
>
{p.name}
</button>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={() => deletePreset.mutate(p.id)}
>
<Trash2 className="size-3.5 text-muted-foreground" />
</Button>
</div>
))
) : (
<p className="text-sm text-muted-foreground px-2 py-1.5">Keine gespeicherten Filter.</p>
)}
<div className="border-t pt-1 mt-1">
{showSavePreset ? (
<div className="flex items-center gap-1.5 px-1">
<Input
value={presetName}
onChange={(e) => 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
/>
<Button size="sm" className="h-8 px-2" onClick={savePreset} disabled={!presetName.trim() || createPreset.isPending}>
<Save className="size-3.5" />
</Button>
</div>
) : (
<button
className="w-full text-left text-sm px-2 py-1.5 rounded hover:bg-accent transition-colors text-muted-foreground"
onClick={() => setShowSavePreset(true)}
>
+ Aktuellen Filter speichern
</button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div> </div>
)} )}