diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py
index 7684986..8f3ff17 100644
--- a/app/plugins/builtins/automation/plugin.py
+++ b/app/plugins/builtins/automation/plugin.py
@@ -99,6 +99,13 @@ class AutomationPlugin(BasePlugin):
icon="Tag",
order=55,
),
+ FrontendMenuItem(
+ label_key="nav.activity",
+ label="Aktivitäten",
+ path="/activity",
+ icon="Activity",
+ order=56,
+ ),
],
page_routes=[
FrontendPageRoute(
diff --git a/frontend/src/components/activity/ActivityFilter.tsx b/frontend/src/components/activity/ActivityFilter.tsx
new file mode 100644
index 0000000..e670683
--- /dev/null
+++ b/frontend/src/components/activity/ActivityFilter.tsx
@@ -0,0 +1,151 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Filter, RotateCcw } from 'lucide-react';
+import { Select } from '@/components/ui/Select';
+import { Button } from '@/components/ui/Button';
+
+export interface ActivityFilterValues {
+ user?: string;
+ entity_type?: string;
+ action?: string;
+ date_from?: string;
+ date_to?: string;
+}
+
+export interface ActivityFilterProps {
+ onFilter: (filters: ActivityFilterValues) => void;
+ initialValues?: ActivityFilterValues;
+}
+
+const ENTITY_TYPE_OPTIONS = [
+ { value: '', label: 'Alle' },
+ { value: 'contact', label: 'Kontakt' },
+ { value: 'mail', label: 'E-Mail' },
+ { value: 'calendar', label: 'Kalender' },
+ { value: 'dms', label: 'Dokument' },
+ { value: 'task', label: 'Aufgabe' },
+];
+
+const ACTION_OPTIONS = [
+ { value: '', label: 'Alle' },
+ { value: 'create', label: 'Erstellt' },
+ { value: 'update', label: 'Aktualisiert' },
+ { value: 'delete', label: 'Gelöscht' },
+];
+
+export function ActivityFilter({ onFilter, initialValues }: ActivityFilterProps) {
+ const { t } = useTranslation();
+ const [user, setUser] = useState(initialValues?.user ?? '');
+ const [entityType, setEntityType] = useState(initialValues?.entity_type ?? '');
+ const [action, setAction] = useState(initialValues?.action ?? '');
+ const [dateFrom, setDateFrom] = useState(initialValues?.date_from ?? '');
+ const [dateTo, setDateTo] = useState(initialValues?.date_to ?? '');
+
+ const handleApply = (e: React.FormEvent) => {
+ e.preventDefault();
+ onFilter({
+ user: user.trim() || undefined,
+ entity_type: entityType || undefined,
+ action: action || undefined,
+ date_from: dateFrom || undefined,
+ date_to: dateTo || undefined,
+ });
+ };
+
+ const handleReset = () => {
+ setUser('');
+ setEntityType('');
+ setAction('');
+ setDateFrom('');
+ setDateTo('');
+ onFilter({});
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/common/EntityHistoryPanel.tsx b/frontend/src/components/common/EntityHistoryPanel.tsx
new file mode 100644
index 0000000..5974362
--- /dev/null
+++ b/frontend/src/components/common/EntityHistoryPanel.tsx
@@ -0,0 +1,387 @@
+/**
+ * EntityHistoryPanel — Vertical timeline of entity history entries.
+ * Shows action badges, timestamps, user info, expandable diffs,
+ * per-entry restore, and undo-last-action.
+ */
+
+import React, { useState, useCallback } from 'react';
+import clsx from 'clsx';
+import {
+ PlusCircle,
+ Pencil,
+ Trash2,
+ RotateCcw,
+ Undo2,
+ ChevronDown,
+ ChevronRight,
+ Clock,
+ User as UserIcon,
+ History,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import {
+ useEntityHistory,
+ useRestoreFromHistory,
+ useUndoLastAction,
+ type EntityHistoryEntry,
+} from '../../api/entityHistory';
+import { Card } from '../ui/Card';
+import { Button } from '../ui/Button';
+import { Badge, type BadgeVariant } from '../ui/Badge';
+import { HistoryDiff } from './HistoryDiff';
+
+export interface EntityHistoryPanelProps {
+ entityType: string;
+ entityId: string;
+ className?: string;
+}
+
+/**
+ * Map action type to badge variant + icon + label.
+ */
+function actionMeta(action: EntityHistoryEntry['action']): {
+ variant: BadgeVariant;
+ icon: React.ReactNode;
+} {
+ switch (action) {
+ case 'create':
+ return { variant: 'success', icon: };
+ case 'update':
+ return { variant: 'info', icon: };
+ case 'delete':
+ return { variant: 'danger', icon: };
+ default:
+ return { variant: 'secondary', icon: };
+ }
+}
+
+/**
+ * Format an ISO date string into a human-readable timestamp.
+ */
+function formatTimestamp(iso: string): string {
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return iso;
+ return d.toLocaleString();
+}
+
+/**
+ * Single timeline entry row — expandable for update diffs.
+ */
+function TimelineEntry({
+ entry,
+ onRestore,
+ restoringId,
+}: {
+ entry: EntityHistoryEntry;
+ onRestore: (id: string) => void;
+ restoringId: string | null;
+}) {
+ const { t } = useTranslation();
+ const [expanded, setExpanded] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+ const meta = actionMeta(entry.action);
+ const hasChanges = entry.changes && Object.keys(entry.changes).length > 0;
+ const isRestoring = restoringId === entry.id;
+
+ const handleRestoreClick = useCallback(() => {
+ if (!confirming) {
+ setConfirming(true);
+ return;
+ }
+ onRestore(entry.id);
+ setConfirming(false);
+ }, [confirming, entry.id, onRestore]);
+
+ const handleCancelConfirm = useCallback(() => {
+ setConfirming(false);
+ }, []);
+
+ return (
+
+ {/* Timeline line + dot */}
+
+
+ {/* Content */}
+
+
+
+ {t(`entityHistory.actions.${entry.action}`, entry.action)}
+
+
+
+ {formatTimestamp(entry.created_at)}
+
+ {entry.user_id && (
+
+
+ {entry.user_id}
+
+ )}
+
+
+ {/* Expand toggle for update entries with changes */}
+ {entry.action === 'update' && hasChanges && (
+
+ )}
+
+ {/* Expandable diff */}
+ {expanded && hasChanges && (
+
+
+
+ )}
+
+ {/* Snapshot before for delete entries */}
+ {entry.action === 'delete' && entry.snapshot_before && (
+
+
+ {t('entityHistory.snapshotBefore', 'Zustand vor Löschung')}
+
+
+ {JSON.stringify(entry.snapshot_before, null, 2)}
+
+
+ )}
+
+ {/* Restore action */}
+
+ {!confirming ? (
+ }
+ onClick={handleRestoreClick}
+ isLoading={isRestoring}
+ disabled={isRestoring}
+ >
+ {t('entityHistory.restore', 'Wiederherstellen')}
+
+ ) : (
+ <>
+
+ {t('entityHistory.confirmRestore', 'Wirklich wiederherstellen?')}
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+/**
+ * Loading skeleton — 3 placeholder entries.
+ */
+function LoadingSkeleton() {
+ return (
+
+ {[0, 1, 2].map(i => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Empty state.
+ */
+function EmptyState() {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('entityHistory.empty', 'Kein Änderungsverlauf vorhanden.')}
+
+
+ );
+}
+
+export function EntityHistoryPanel({ entityType, entityId, className }: EntityHistoryPanelProps) {
+ const { t } = useTranslation();
+ const { data, isLoading, isError, error, refetch } = useEntityHistory(entityType, entityId);
+ const restoreMutation = useRestoreFromHistory();
+ const undoMutation = useUndoLastAction();
+
+ const [restoringId, setRestoringId] = useState(null);
+ const [undoConfirm, setUndoConfirm] = useState(false);
+
+ const entries = data?.items ?? [];
+
+ const handleRestore = useCallback(
+ (historyId: string) => {
+ setRestoringId(historyId);
+ restoreMutation.mutate(historyId, {
+ onSettled: () => setRestoringId(null),
+ });
+ },
+ [restoreMutation]
+ );
+
+ const handleUndo = useCallback(() => {
+ if (!undoConfirm) {
+ setUndoConfirm(true);
+ return;
+ }
+ undoMutation.mutate(
+ { entityType, entityId },
+ { onSettled: () => setUndoConfirm(false) }
+ );
+ }, [undoConfirm, undoMutation, entityType, entityId]);
+
+ const handleUndoCancel = useCallback(() => setUndoConfirm(false), []);
+
+ const hasHistory = entries.length > 0;
+
+ return (
+
+
+ {t('entityHistory.confirmUndo', 'Letzte Aktion rückgängig machen?')}
+
+ }
+ onClick={handleUndo}
+ isLoading={undoMutation.isPending}
+ disabled={undoMutation.isPending}
+ >
+ {t('entityHistory.yes', 'Ja')}
+
+
+
+ ) : (
+ }
+ onClick={handleUndo}
+ disabled={undoMutation.isPending}
+ >
+ {t('entityHistory.undoLast', 'Letzte Aktion rückgängig')}
+
+ )
+ ) : undefined
+ }
+ >
+ {isLoading ? (
+
+ ) : isError ? (
+
+
+ {t('entityHistory.loadError', 'Fehler beim Laden des Verlaufs')}
+
+
+ {error instanceof Error ? error.message : String(error)}
+
+
+
+ ) : !hasHistory ? (
+
+ ) : (
+
+ {entries.map(entry => (
+
+ ))}
+
+ )}
+
+ {/* Restore error display */}
+ {restoreMutation.isError && (
+
+
+ {t('entityHistory.restoreError', 'Fehler beim Wiederherstellen')}
+ {restoreMutation.error instanceof Error
+ ? `: ${restoreMutation.error.message}`
+ : ''}
+
+
+ )}
+
+ {/* Undo error display */}
+ {undoMutation.isError && (
+
+
+ {t('entityHistory.undoError', 'Fehler beim Rückgängigmachen')}
+ {undoMutation.error instanceof Error
+ ? `: ${undoMutation.error.message}`
+ : ''}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/common/HistoryDiff.tsx b/frontend/src/components/common/HistoryDiff.tsx
new file mode 100644
index 0000000..428be46
--- /dev/null
+++ b/frontend/src/components/common/HistoryDiff.tsx
@@ -0,0 +1,107 @@
+/**
+ * HistoryDiff — Visual diff table of field changes for an entity history entry.
+ * Shows old value (red strikethrough) → new value (green) per field.
+ */
+
+import React from 'react';
+import clsx from 'clsx';
+import { ArrowRight } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+export interface HistoryDiffProps {
+ changes: Record;
+ className?: string;
+}
+
+/**
+ * Format a value for display, handling null/undefined gracefully.
+ */
+function formatValue(value: any): string {
+ if (value === null || value === undefined) {
+ return '—';
+ }
+ if (typeof value === 'boolean') {
+ return value ? 'true' : 'false';
+ }
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+ return String(value);
+}
+
+export function HistoryDiff({ changes, className }: HistoryDiffProps) {
+ const { t } = useTranslation();
+ const entries = Object.entries(changes);
+
+ if (entries.length === 0) {
+ return (
+
+ {t('entityHistory.noChanges', 'Keine Änderungen')}
+
+ );
+ }
+
+ return (
+
+
+
+
+ |
+ {t('entityHistory.field', 'Feld')}
+ |
+
+ {t('entityHistory.oldValue', 'Alter Wert')}
+ |
+ |
+
+ {t('entityHistory.newValue', 'Neuer Wert')}
+ |
+
+
+
+ {entries.map(([field, change]) => {
+ const oldDisplay = formatValue(change.old);
+ const newDisplay = formatValue(change.new);
+ const isOldEmpty = change.old === null || change.old === undefined;
+ const isNewEmpty = change.new === null || change.new === undefined;
+
+ return (
+
+ |
+ {field}
+ |
+
+ {oldDisplay}
+ |
+
+
+ |
+
+ {newDisplay}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/common/SaveFilterDialog.tsx b/frontend/src/components/common/SaveFilterDialog.tsx
new file mode 100644
index 0000000..832792c
--- /dev/null
+++ b/frontend/src/components/common/SaveFilterDialog.tsx
@@ -0,0 +1,168 @@
+/**
+ * SaveFilterDialog — Modal dialog for saving the current filter criteria
+ * as a named, reusable saved filter.
+ *
+ * Shows a name input plus a human-readable summary of the active
+ * filter criteria. On save, calls the createSavedFilter mutation,
+ * shows a success toast, and closes.
+ */
+
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Modal } from '@/components/ui/Modal';
+import { Input } from '@/components/ui/Input';
+import { Button } from '@/components/ui/Button';
+import { useToast } from '@/components/ui/Toast';
+import { useCreateSavedFilter } from '@/api/savedFilters';
+
+export interface SaveFilterDialogProps {
+ open: boolean;
+ entityType: string;
+ currentFilters: Record;
+ onClose: () => void;
+}
+
+/**
+ * Renders a compact key-value summary of the current filter criteria.
+ * Empty / null / undefined values are omitted.
+ */
+function CriteriaSummary({ criteria }: { criteria: Record }) {
+ const entries = Object.entries(criteria).filter(
+ ([, v]) => v !== null && v !== undefined && v !== ''
+ );
+
+ if (entries.length === 0) {
+ return (
+
+ Keine aktiven Filterkriterien
+
+ );
+ }
+
+ return (
+
+ {entries.map(([key, value]) => (
+
+
-
+ {key.replace(/_/g, ' ')}
+
+ -
+ {Array.isArray(value)
+ ? value.join(', ')
+ : typeof value === 'object'
+ ? JSON.stringify(value)
+ : String(value)}
+
+
+ ))}
+
+ );
+}
+
+export function SaveFilterDialog({
+ open,
+ entityType,
+ currentFilters,
+ onClose,
+}: SaveFilterDialogProps) {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const [name, setName] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const createMutation = useCreateSavedFilter();
+
+ // Reset name when dialog opens
+ useEffect(() => {
+ if (open) {
+ setName('');
+ setSubmitting(false);
+ }
+ }, [open]);
+
+ const hasCriteria = Object.values(currentFilters).some(
+ (v) => v !== null && v !== undefined && v !== ''
+ );
+
+ const handleSave = async () => {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ setSubmitting(true);
+ try {
+ await createMutation.mutateAsync({
+ name: trimmed,
+ entity_type: entityType,
+ filter_criteria: currentFilters,
+ });
+ toast.success(
+ t('savedFilters.saveSuccess', 'Filter gespeichert')
+ );
+ setName('');
+ onClose();
+ } catch (err: any) {
+ toast.error(
+ err?.message || t('savedFilters.saveError', 'Filter konnte nicht gespeichert werden')
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && name.trim() && !submitting) {
+ e.preventDefault();
+ handleSave();
+ }
+ };
+
+ return (
+
+
+ {/* Name input */}
+
setName(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={t(
+ 'savedFilters.namePlaceholder',
+ 'z.B. Wichtige Kunden'
+ )}
+ data-testid="filter-name-input"
+ autoFocus
+ />
+
+ {/* Criteria summary */}
+
+
+ {t('savedFilters.criteriaSummary', 'Aktuelle Filterkriterien')}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/common/SavedFilterBar.tsx b/frontend/src/components/common/SavedFilterBar.tsx
new file mode 100644
index 0000000..76dc3d9
--- /dev/null
+++ b/frontend/src/components/common/SavedFilterBar.tsx
@@ -0,0 +1,171 @@
+/**
+ * SavedFilterBar — Reusable filter bar with a dropdown of saved filters.
+ *
+ * Features:
+ * • Dropdown listing saved filters for the given entity type
+ * • Click a filter to apply its criteria via onApplyFilter
+ * • Delete button per filter (with stopPropagation so it doesn't apply)
+ * • "Speichern" button opens the SaveFilterDialog for the current criteria
+ * • Shows the currently active filter name when one is selected
+ * • Click-outside-to-close dropdown behaviour
+ */
+
+import React, { useState, useRef, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/Button';
+import { useToast } from '@/components/ui/Toast';
+import { SaveFilterDialog } from '@/components/common/SaveFilterDialog';
+import {
+ useSavedFilters,
+ useDeleteSavedFilter,
+ type SavedFilter,
+} from '@/api/savedFilters';
+import {
+ ChevronDown,
+ Bookmark,
+ Trash2,
+ Save,
+ Filter,
+ Check,
+} from 'lucide-react';
+
+export interface SavedFilterBarProps {
+ entityType: string;
+ currentFilters: Record;
+ onApplyFilter: (criteria: Record) => void;
+}
+
+export function SavedFilterBar({
+ entityType,
+ currentFilters,
+ onApplyFilter,
+}: SavedFilterBarProps) {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [activeFilterId, setActiveFilterId] = useState(null);
+ const dropdownRef = useRef(null);
+
+ const { data: savedFilters = [], isLoading } = useSavedFilters(entityType);
+ const deleteMutation = useDeleteSavedFilter();
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ if (!dropdownOpen) return;
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node)
+ ) {
+ setDropdownOpen(false);
+ }
+ }; document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [dropdownOpen]);
+
+ // Close dropdown on Escape
+ useEffect(() => {
+ if (!dropdownOpen) return; const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setDropdownOpen(false);
+ }; document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [dropdownOpen]);
+
+ const activeFilter = activeFilterId
+ ? savedFilters.find((f) => f.id === activeFilterId)
+ : null;
+
+ const handleApply = useCallback(
+ (filter: SavedFilter) => {
+ setActiveFilterId(filter.id);
+ onApplyFilter(filter.filter_criteria);
+ setDropdownOpen(false);
+ }, [onApplyFilter] );
+
+ const handleDelete = useCallback(
+ async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation(); try { await deleteMutation.mutateAsync(id); if (activeFilterId === id) setActiveFilterId(null); toast.success(t('savedFilters.deleted', 'Filter gelöscht'));
+ } catch (err: any) {
+ toast.error(err?.message || t('common.error', 'Fehler'));
+ } }, [deleteMutation, activeFilterId, toast, t]
+ );
+
+ const toggleDropdown = () => setDropdownOpen((prev) => !prev);
+
+ return (
+ {/* Dropdown trigger */}
+
+
{/* Dropdown menu */}
+ {dropdownOpen && (
+ {isLoading && (
+
+ {t('common.loading', 'Laden…')}
+
)}
+ {!isLoading && savedFilters.length === 0 && (
+
+ {t('savedFilters.empty', 'Keine gespeicherten Filter')}
+
+ )}
+ {savedFilters.map((filter) => (
+
handleApply(filter)}
+ className="flex items-center justify-between gap-2 px-3 py-2 hover:bg-secondary-50 cursor-pointer group"
+ data-testid={`saved-filter-item-${filter.id}`}
+ >
+ {activeFilterId === filter.id ? (
+
+ ) : (
+
+ )}
+ {filter.name}
+
+
+
+ ))}
+
+ )}
+
{/* Save current filters button */}
+
}
+ onClick={() => setDialogOpen(true)}
+ data-testid="save-filter-btn"
+ >
+ {t('savedFilters.save', 'Speichern')} {/* Save filter dialog */}
setDialogOpen(false)}
+ />
+ );}
diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx
index 2d757a3..1296d28 100644
--- a/frontend/src/components/layout/TopBar.tsx
+++ b/frontend/src/components/layout/TopBar.tsx
@@ -8,7 +8,7 @@ import { useLogout } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar';
import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
-import { Building, ChevronDown, Menu, Zap, Bot, Layers } from 'lucide-react';
+import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
import { NotificationBell } from '@/components/layout/NotificationBell';
import { useWindowStore } from '@/store/windowStore';
@@ -160,6 +160,17 @@ export function TopBar() {
>
{t('nav.auditLog')}
+ setUserMenuOpen(false)}
+ className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 flex items-center gap-2"
+ role="menuitem"
+ >
+
+ {t('settings.apiDocs', 'API Dokumentation')}
+