diff --git a/alembic/versions/0029_saved_filters.py b/alembic/versions/0029_saved_filters.py new file mode 100644 index 0000000..5290d40 --- /dev/null +++ b/alembic/versions/0029_saved_filters.py @@ -0,0 +1,40 @@ +"""saved_filters table + +Revision ID: 0029_saved_filters +Revises: 0028_user_preferences +Create Date: 2025-07-23 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + +# revision identifiers +revision = "0029_saved_filters" +down_revision = "0028_user_preferences" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "saved_filters", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("tenant_id", UUID(as_uuid=True), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("entity_type", sa.String(50), nullable=False), + sa.Column("filter_criteria", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"), + ) + op.create_index("ix_saved_filters_tenant_user", "saved_filters", ["tenant_id", "user_id"]) + op.create_index("ix_saved_filters_tenant_entity", "saved_filters", ["tenant_id", "entity_type"]) + + +def downgrade() -> None: + op.drop_index("ix_saved_filters_tenant_entity", table_name="saved_filters") + op.drop_index("ix_saved_filters_tenant_user", table_name="saved_filters") + op.drop_table("saved_filters") diff --git a/app/main.py b/app/main.py index 1c7329c..9a874bd 100644 --- a/app/main.py +++ b/app/main.py @@ -48,6 +48,7 @@ from app.routes import ( system_settings, attachments, custom_fields, + saved_filters, ) @@ -306,6 +307,7 @@ def create_app() -> FastAPI: app.include_router(addresses.router) app.include_router(audit.router) app.include_router(custom_fields.router) + app.include_router(saved_filters.router) # ── Register plugin routes (before SPA catch-all) ────────────────── registry = get_registry() diff --git a/app/models/saved_filter.py b/app/models/saved_filter.py new file mode 100644 index 0000000..0859be0 --- /dev/null +++ b/app/models/saved_filter.py @@ -0,0 +1,36 @@ +"""SavedFilter model — tenant-scoped, user-scoped saved filter criteria.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import ForeignKey, Index, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, TenantMixin + + +class SavedFilter(Base, TenantMixin): + """Saved filter — reusable filter criteria for list views (contacts, mail, calendar, DMS).""" + + __tablename__ = "saved_filters" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"), + Index("ix_saved_filters_tenant_user", "tenant_id", "user_id"), + Index("ix_saved_filters_tenant_entity", "tenant_id", "entity_type"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + entity_type: Mapped[str] = mapped_column( + String(50), nullable=False + ) # contacts, mail, calendar, dms + filter_criteria: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + user_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) diff --git a/app/routes/saved_filters.py b/app/routes/saved_filters.py new file mode 100644 index 0000000..9809786 --- /dev/null +++ b/app/routes/saved_filters.py @@ -0,0 +1,136 @@ +"""Saved filters routes — CRUD for reusable filter criteria.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.deps import get_current_user, require_permission +from app.models.saved_filter import SavedFilter + +router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"]) + +VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"} + + +class SavedFilterCreate(BaseModel): + """Schema for creating a saved filter.""" + name: str = Field(..., min_length=1, max_length=100) + entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$") + filter_criteria: dict[str, Any] = Field(default_factory=dict) + + +class SavedFilterUpdate(BaseModel): + """Schema for updating a saved filter.""" + name: str | None = Field(default=None, min_length=1, max_length=100) + filter_criteria: dict[str, Any] | None = None + + +def _filter_to_dict(f: SavedFilter) -> dict[str, Any]: + return { + "id": str(f.id), + "name": f.name, + "entity_type": f.entity_type, + "filter_criteria": f.filter_criteria, + "user_id": str(f.user_id), + "created_at": f.created_at.isoformat() if f.created_at else None, + "updated_at": f.updated_at.isoformat() if f.updated_at else None, + } + + +@router.get("", dependencies=[Depends(require_permission("contacts:read"))]) +async def list_saved_filters( + entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List saved filters for the current user, optionally filtered by entity_type.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + query = select(SavedFilter).where( + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.deleted_at.is_(None), + ) + if entity_type: + query = query.where(SavedFilter.entity_type == entity_type) + query = query.order_by(SavedFilter.name) + + result = await db.execute(query) + filters = result.scalars().all() + return [_filter_to_dict(f) for f in filters] + + +@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))]) +async def create_saved_filter( + body: SavedFilterCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new saved filter for the current user.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + # Check uniqueness within user+entity + existing = await db.execute( + select(SavedFilter).where( + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.entity_type == body.entity_type, + SavedFilter.name == body.name, + SavedFilter.deleted_at.is_(None), + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"}) + + saved = SavedFilter( + tenant_id=tenant_id, + user_id=user_id, + name=body.name, + entity_type=body.entity_type, + filter_criteria=body.filter_criteria, + ) + db.add(saved) + await db.flush() + return _filter_to_dict(saved) + + +@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))]) +async def delete_saved_filter( + filter_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a saved filter (soft-delete).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + try: + fid = uuid.UUID(filter_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None + + result = await db.execute( + select(SavedFilter).where( + SavedFilter.id == fid, + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.deleted_at.is_(None), + ) + ) + saved = result.scalar_one_or_none() + if saved is None: + raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"}) + + from datetime import datetime, timezone + saved.deleted_at = datetime.now(timezone.utc) + await db.flush() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/frontend/src/__tests__/SavedFilters.test.tsx b/frontend/src/__tests__/SavedFilters.test.tsx new file mode 100644 index 0000000..dedc5a2 --- /dev/null +++ b/frontend/src/__tests__/SavedFilters.test.tsx @@ -0,0 +1,114 @@ +/** + * SavedFilters component tests. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SavedFilters } from '@/components/SavedFilters'; + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Mock toast +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})); + +// Mock UI components +vi.mock('@/components/ui/Input', () => ({ + Input: ({ value, onChange, label, ...props }: any) => ( +
+ {label && } + +
+ ), +})); + +vi.mock('@/components/ui/Modal', () => ({ + Modal: ({ open, onClose, title, children }: any) => + open ? ( +
+

{title}

+ + {children} +
+ ) : null, +})); + +vi.mock('@/components/ui/Button', () => ({ + Button: ({ children, onClick, disabled, ...props }: any) => ( + + ), +})); + +// Mock saved filters API +vi.mock('@/api/savedFilters', () => ({ + useSavedFilters: vi.fn(() => ({ + data: [ + { + id: 'filter-1', + name: 'Important Clients', + entity_type: 'contacts', + filter_criteria: { type: 'company' }, + user_id: 'user-1', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + ], + })), + useCreateSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + useDeleteSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn() })), +})); + +describe('SavedFilters', () => { + it('renders saved filter buttons', () => { + render( + + ); + expect(screen.getByTestId('saved-filters')).toBeInTheDocument(); + expect(screen.getByText('Important Clients')).toBeInTheDocument(); + }); + + it('opens save modal when save button is clicked', () => { + render( + + ); + fireEvent.click(screen.getByTestId('save-filter-btn')); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('filter-name-input')).toBeInTheDocument(); + }); + + it('calls onLoadFilter when a saved filter is clicked', () => { + const onLoadFilter = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Important Clients')); + expect(onLoadFilter).toHaveBeenCalledWith({ type: 'company' }); + }); +}); diff --git a/frontend/src/api/savedFilters.ts b/frontend/src/api/savedFilters.ts new file mode 100644 index 0000000..ddc1147 --- /dev/null +++ b/frontend/src/api/savedFilters.ts @@ -0,0 +1,51 @@ +/** + * Saved filters API hooks — CRUD for reusable filter criteria. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiDelete } from './client'; + +export interface SavedFilter { + id: string; + name: string; + entity_type: string; + filter_criteria: Record; + user_id: string; + created_at: string; + updated_at: string; +} + +export interface SavedFilterCreateInput { + name: string; + entity_type: string; + filter_criteria: Record; +} + +export function useSavedFilters(entityType?: string) { + const params = new URLSearchParams(); + if (entityType) params.set('entity_type', entityType); + return useQuery({ + queryKey: ['saved-filters', entityType], + queryFn: () => apiGet(`/saved-filters?${params.toString()}`), + }); +} + +export function useCreateSavedFilter() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: SavedFilterCreateInput) => apiPost('/saved-filters', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['saved-filters'] }); + }, + }); +} + +export function useDeleteSavedFilter() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/saved-filters/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['saved-filters'] }); + }, + }); +} diff --git a/frontend/src/components/SavedFilters.tsx b/frontend/src/components/SavedFilters.tsx new file mode 100644 index 0000000..ae226de --- /dev/null +++ b/frontend/src/components/SavedFilters.tsx @@ -0,0 +1,119 @@ +/** + * SavedFilters — filter-builder UI, save button, load saved filters. + * Integrates into list views (Contacts, Mail, Calendar, DMS). + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { useToast } from '@/components/ui/Toast'; +import { Bookmark, Trash2, Save } from 'lucide-react'; +import { + useSavedFilters, + useCreateSavedFilter, + useDeleteSavedFilter, + type SavedFilter, +} from '@/api/savedFilters'; + +export interface SavedFiltersProps { + entityType: string; + currentCriteria: Record; + onLoadFilter: (criteria: Record) => void; +} + +export function SavedFilters({ entityType, currentCriteria, onLoadFilter }: SavedFiltersProps) { + const { t } = useTranslation(); + const toast = useToast(); + const [saveModalOpen, setSaveModalOpen] = useState(false); + const [filterName, setFilterName] = useState(''); + + const { data: savedFilters = [] } = useSavedFilters(entityType); + const createMutation = useCreateSavedFilter(); + const deleteMutation = useDeleteSavedFilter(); + + const handleSave = async () => { + if (!filterName.trim()) return; + try { + await createMutation.mutateAsync({ + name: filterName.trim(), + entity_type: entityType, + filter_criteria: currentCriteria, + }); + toast.success(t('savedFilters.saved')); + setFilterName(''); + setSaveModalOpen(false); + } catch (err: any) { + toast.error(err.message || t('common.error')); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteMutation.mutateAsync(id); + toast.success(t('savedFilters.deleted')); + } catch (err: any) { + toast.error(err.message || t('common.error')); + } + }; + + const handleLoad = (filter: SavedFilter) => { + onLoadFilter(filter.filter_criteria); + }; + + return ( +
+ {/* Saved filter tabs/buttons */} + {savedFilters.map((filter) => ( +
handleLoad(filter)} + data-testid={`saved-filter-${filter.id}`} + > + + {filter.name} + +
+ ))} + + {/* Save current filter button */} + + + {/* Save modal */} + setSaveModalOpen(false)} title={t('savedFilters.saveTitle')} size="sm"> +
+ setFilterName(e.target.value)} + placeholder={t('savedFilters.namePlaceholder')} + data-testid="filter-name-input" + autoFocus + /> +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index fec7c67..5e6907d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1022,5 +1022,15 @@ "priorityMedium": "Mittel", "priorityHigh": "Hoch", "priorityUrgent": "Dringend" + }, + "savedFilters": { + "save": "Filter speichern", + "saveTitle": "Filter speichern", + "name": "Filtername", + "namePlaceholder": "z.B. Wichtige Kunden", + "saved": "Filter gespeichert", + "deleted": "Filter gelöscht", + "load": "Filter laden", + "noFilters": "Keine gespeicherten Filter" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index af074cf..f8fa9b1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1022,5 +1022,15 @@ "priorityMedium": "Medium", "priorityHigh": "High", "priorityUrgent": "Urgent" + }, + "savedFilters": { + "save": "Save Filter", + "saveTitle": "Save Filter", + "name": "Filter Name", + "namePlaceholder": "e.g. Important Clients", + "saved": "Filter saved", + "deleted": "Filter deleted", + "load": "Load Filter", + "noFilters": "No saved filters" } } diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index 912915d..d41fd91 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -16,6 +16,7 @@ import { ContactFolderTree, type ContactFilter } from '@/components/contacts/Con import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList'; import { ContactDetail } from '@/components/contacts/ContactDetail'; import { ContactEditModal } from '@/components/contacts/ContactEditModal'; +import { SavedFilters } from '@/components/SavedFilters'; import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react'; import { useUnifiedContacts, @@ -328,6 +329,21 @@ export function ContactsListPage() { + {/* Saved Filters */} +
+ { + if (criteria.search) setSearch(criteria.search); else setSearch(''); + if (criteria.sortBy) setSortBy(criteria.sortBy); + if (criteria.sortOrder) setSortOrder(criteria.sortOrder); + if (criteria.type) setSelectedFilter(criteria.type as ContactFilter); + setPage(1); + }} + /> +
+ {/* List */}