From 10dcc8ae90d9b293f57c5f7bfd96a03b1b2ce9ed Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 26 Jul 2026 03:08:26 +0200 Subject: [PATCH] Phase 3: Saved Filters UI, Entity History UI, Activity Timeline, API Docs Link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Saved Filters: SavedFilterBar (dropdown, apply, delete), SaveFilterDialog (name + save) - Entity History: EntityHistoryPanel (timeline, restore, undo), HistoryDiff (field changes visual) - Activity Timeline: ActivityTimelinePage (grouped by day, pagination), ActivityFilter (user/entity/action/date) - API Docs Link: TopBar user menu entry, SettingsSystem Entwickler section (Swagger, ReDoc, OpenAPI) - Routes: /activity registered - Menu items: Aktivitäten added to automation plugin manifest --- app/plugins/builtins/automation/plugin.py | 7 + .../components/activity/ActivityFilter.tsx | 151 +++++++ .../components/common/EntityHistoryPanel.tsx | 387 ++++++++++++++++++ .../src/components/common/HistoryDiff.tsx | 107 +++++ .../components/common/SaveFilterDialog.tsx | 168 ++++++++ .../src/components/common/SavedFilterBar.tsx | 171 ++++++++ frontend/src/components/layout/TopBar.tsx | 13 +- frontend/src/pages/ActivityTimeline.tsx | 202 +++++++++ frontend/src/pages/SettingsSystem.tsx | 56 +++ frontend/src/routes/index.tsx | 2 + 10 files changed, 1263 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/activity/ActivityFilter.tsx create mode 100644 frontend/src/components/common/EntityHistoryPanel.tsx create mode 100644 frontend/src/components/common/HistoryDiff.tsx create mode 100644 frontend/src/components/common/SaveFilterDialog.tsx create mode 100644 frontend/src/components/common/SavedFilterBar.tsx create mode 100644 frontend/src/pages/ActivityTimeline.tsx 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 ( +
+
+
+ + setUser(e.target.value)} + placeholder="Benutzername" + className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch text-secondary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+ + setAction(e.target.value)} + /> + +
+ + setDateFrom(e.target.value)} + className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch text-secondary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+ +
+ + setDateTo(e.target.value)} + className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch text-secondary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+
+ +
+ + +
+
+ ); +} 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 */} +
+
+ {meta.icon} +
+
+
+ + {/* Content */} +
+
+ + {t(`entityHistory.actions.${entry.action}`, entry.action)} + + + + {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 ? ( + + ) : ( + <> + + {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 ( +
+
+ ); +} + +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?')} + + + +
+ ) : ( + + ) + ) : 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 ( +
+ + + + + + + + + + {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 ( + + + + + + + ); + })} + +
+ {t('entityHistory.field', 'Feld')} + + {t('entityHistory.oldValue', 'Alter Wert')} + + {t('entityHistory.newValue', 'Neuer Wert')} +
+ {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 ? ( +
+ +
+ ))} +
+ )} +
{/* Save current filters button */} + {/* 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" + > +