Phase 3: Saved Filters UI, Entity History UI, Activity Timeline, API Docs Link

- 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
This commit is contained in:
Agent Zero
2026-07-26 03:08:26 +02:00
parent a7e3890634
commit 10dcc8ae90
10 changed files with 1263 additions and 1 deletions
@@ -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 (
<form
onSubmit={handleApply}
className="bg-white rounded-lg shadow-sm border border-secondary-200 p-4 mb-6"
data-testid="activity-filter"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="w-full">
<label
htmlFor="activity-filter-user"
className="block text-sm font-medium text-secondary-700 mb-1"
>
{t('activity.filterUser', 'Benutzer')}
</label>
<input
id="activity-filter-user"
type="text"
value={user}
onChange={(e) => 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"
/>
</div>
<Select
label={t('activity.filterEntityType', 'Entitätstyp')}
options={ENTITY_TYPE_OPTIONS}
value={entityType}
onChange={(e) => setEntityType(e.target.value)}
/>
<Select
label={t('activity.filterAction', 'Aktion')}
options={ACTION_OPTIONS}
value={action}
onChange={(e) => setAction(e.target.value)}
/>
<div className="w-full">
<label
htmlFor="activity-filter-date-from"
className="block text-sm font-medium text-secondary-700 mb-1"
>
{t('activity.filterDateFrom', 'Von Datum')}
</label>
<input
id="activity-filter-date-from"
type="date"
value={dateFrom}
onChange={(e) => 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"
/>
</div>
<div className="w-full">
<label
htmlFor="activity-filter-date-to"
className="block text-sm font-medium text-secondary-700 mb-1"
>
{t('activity.filterDateTo', 'Bis Datum')}
</label>
<input
id="activity-filter-date-to"
type="date"
value={dateTo}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-center gap-3 mt-4">
<Button type="submit" variant="primary" size="md" icon={<Filter className="h-4 w-4" />}>
{t('activity.applyFilter', 'Filter anwenden')}
</Button>
<Button
type="button"
variant="secondary"
size="md"
icon={<RotateCcw className="h-4 w-4" />}
onClick={handleReset}
>
{t('activity.resetFilter', 'Zurücksetzen')}
</Button>
</div>
</form>
);
}
@@ -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: <PlusCircle className="h-3.5 w-3.5" /> };
case 'update':
return { variant: 'info', icon: <Pencil className="h-3.5 w-3.5" /> };
case 'delete':
return { variant: 'danger', icon: <Trash2 className="h-3.5 w-3.5" /> };
default:
return { variant: 'secondary', icon: <History className="h-3.5 w-3.5" /> };
}
}
/**
* 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 (
<div className="relative flex gap-3 pb-6 last:pb-0">
{/* Timeline line + dot */}
<div className="flex flex-col items-center">
<div
className={clsx(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
meta.variant === 'success' && 'bg-success-100 text-success-600',
meta.variant === 'info' && 'bg-accent-100 text-accent-600',
meta.variant === 'danger' && 'bg-danger-100 text-danger-600',
meta.variant === 'secondary' && 'bg-secondary-100 text-secondary-600'
)}
>
{meta.icon}
</div>
<div className="w-px flex-1 bg-secondary-200 mt-1" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={meta.variant} dot>
{t(`entityHistory.actions.${entry.action}`, entry.action)}
</Badge>
<span className="text-xs text-secondary-500 flex items-center gap-1">
<Clock className="h-3 w-3" aria-hidden="true" />
{formatTimestamp(entry.created_at)}
</span>
{entry.user_id && (
<span className="text-xs text-secondary-500 flex items-center gap-1">
<UserIcon className="h-3 w-3" aria-hidden="true" />
{entry.user_id}
</span>
)}
</div>
{/* Expand toggle for update entries with changes */}
{entry.action === 'update' && hasChanges && (
<button
type="button"
onClick={() => setExpanded(prev => !prev)}
className="mt-1 text-sm text-primary-600 hover:text-primary-700 inline-flex items-center gap-1"
aria-expanded={expanded}
>
{expanded ? (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronRight className="h-4 w-4" aria-hidden="true" />
)}
{expanded
? t('entityHistory.hideChanges', 'Änderungen ausblenden')
: t('entityHistory.showChanges', 'Änderungen anzeigen')}
</button>
)}
{/* Expandable diff */}
{expanded && hasChanges && (
<div className="mt-2 p-3 bg-secondary-50 rounded-md border border-secondary-200">
<HistoryDiff changes={entry.changes!} />
</div>
)}
{/* Snapshot before for delete entries */}
{entry.action === 'delete' && entry.snapshot_before && (
<div className="mt-2 p-3 bg-secondary-50 rounded-md border border-secondary-200">
<p className="text-xs text-secondary-500 mb-2 font-medium">
{t('entityHistory.snapshotBefore', 'Zustand vor Löschung')}
</p>
<pre className="text-xs text-secondary-700 overflow-x-auto">
{JSON.stringify(entry.snapshot_before, null, 2)}
</pre>
</div>
)}
{/* Restore action */}
<div className="mt-2 flex items-center gap-2">
{!confirming ? (
<Button
variant="ghost"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
onClick={handleRestoreClick}
isLoading={isRestoring}
disabled={isRestoring}
>
{t('entityHistory.restore', 'Wiederherstellen')}
</Button>
) : (
<>
<span className="text-sm text-warning-700">
{t('entityHistory.confirmRestore', 'Wirklich wiederherstellen?')}
</span>
<Button
variant="primary"
size="sm"
onClick={handleRestoreClick}
isLoading={isRestoring}
disabled={isRestoring}
>
{t('entityHistory.yes', 'Ja')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCancelConfirm}
disabled={isRestoring}
>
{t('entityHistory.cancel', 'Abbrechen')}
</Button>
</>
)}
</div>
</div>
</div>
);
}
/**
* Loading skeleton — 3 placeholder entries.
*/
function LoadingSkeleton() {
return (
<div className="space-y-4 animate-pulse" aria-label="Loading history">
{[0, 1, 2].map(i => (
<div key={i} className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-secondary-200 flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-secondary-200 rounded w-24" />
<div className="h-3 bg-secondary-100 rounded w-48" />
<div className="h-8 bg-secondary-100 rounded w-full" />
</div>
</div>
))}
</div>
);
}
/**
* Empty state.
*/
function EmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<History className="h-12 w-12 text-secondary-300 mb-3" aria-hidden="true" />
<p className="text-secondary-500 text-sm">
{t('entityHistory.empty', 'Kein Änderungsverlauf vorhanden.')}
</p>
</div>
);
}
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<string | null>(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 (
<Card
title={t('entityHistory.title', 'Änderungsverlauf')}
description={t('entityHistory.description', 'Verlauf aller Änderungen an diesem Eintrag')}
className={className}
data-testid="entity-history-panel"
actions={
hasHistory && !isLoading ? (
undoConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-warning-700">
{t('entityHistory.confirmUndo', 'Letzte Aktion rückgängig machen?')}
</span>
<Button
variant="danger"
size="sm"
icon={<Undo2 className="h-3.5 w-3.5" />}
onClick={handleUndo}
isLoading={undoMutation.isPending}
disabled={undoMutation.isPending}
>
{t('entityHistory.yes', 'Ja')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleUndoCancel}
disabled={undoMutation.isPending}
>
{t('entityHistory.cancel', 'Abbrechen')}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
icon={<Undo2 className="h-3.5 w-3.5" />}
onClick={handleUndo}
disabled={undoMutation.isPending}
>
{t('entityHistory.undoLast', 'Letzte Aktion rückgängig')}
</Button>
)
) : undefined
}
>
{isLoading ? (
<LoadingSkeleton />
) : isError ? (
<div className="py-6 text-center">
<p className="text-danger-600 text-sm mb-2">
{t('entityHistory.loadError', 'Fehler beim Laden des Verlaufs')}
</p>
<p className="text-secondary-400 text-xs mb-3">
{error instanceof Error ? error.message : String(error)}
</p>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
{t('common.retry', 'Erneut versuchen')}
</Button>
</div>
) : !hasHistory ? (
<EmptyState />
) : (
<div className="space-y-0">
{entries.map(entry => (
<TimelineEntry
key={entry.id}
entry={entry}
onRestore={handleRestore}
restoringId={restoringId}
/>
))}
</div>
)}
{/* Restore error display */}
{restoreMutation.isError && (
<div className="mt-4 p-3 bg-danger-50 border border-danger-200 rounded-md">
<p className="text-danger-700 text-sm">
{t('entityHistory.restoreError', 'Fehler beim Wiederherstellen')}
{restoreMutation.error instanceof Error
? `: ${restoreMutation.error.message}`
: ''}
</p>
</div>
)}
{/* Undo error display */}
{undoMutation.isError && (
<div className="mt-4 p-3 bg-danger-50 border border-danger-200 rounded-md">
<p className="text-danger-700 text-sm">
{t('entityHistory.undoError', 'Fehler beim Rückgängigmachen')}
{undoMutation.error instanceof Error
? `: ${undoMutation.error.message}`
: ''}
</p>
</div>
)}
</Card>
);
}
@@ -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<string, { old: any; new: any }>;
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 (
<p className="text-sm text-secondary-500 italic">
{t('entityHistory.noChanges', 'Keine Änderungen')}
</p>
);
}
return (
<div className={clsx('overflow-x-auto', className)}>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-secondary-200">
<th className="text-left font-medium text-secondary-600 py-2 pr-4">
{t('entityHistory.field', 'Feld')}
</th>
<th className="text-left font-medium text-secondary-600 py-2 pr-4">
{t('entityHistory.oldValue', 'Alter Wert')}
</th>
<th className="w-8 py-2" aria-hidden="true" />
<th className="text-left font-medium text-secondary-600 py-2">
{t('entityHistory.newValue', 'Neuer Wert')}
</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={field} className="border-b border-secondary-100 last:border-0">
<td className="py-2 pr-4 font-medium text-secondary-700">
{field}
</td>
<td
className={clsx(
'py-2 pr-4',
isOldEmpty
? 'text-secondary-400 italic'
: 'text-danger-700 line-through'
)}
>
{oldDisplay}
</td>
<td className="py-2 text-center text-secondary-400">
<ArrowRight className="inline h-3.5 w-3.5" aria-hidden="true" />
</td>
<td
className={clsx(
'py-2',
isNewEmpty
? 'text-secondary-400 italic'
: 'text-success-700 font-medium'
)}
>
{newDisplay}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -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<string, any>;
onClose: () => void;
}
/**
* Renders a compact key-value summary of the current filter criteria.
* Empty / null / undefined values are omitted.
*/
function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
const entries = Object.entries(criteria).filter(
([, v]) => v !== null && v !== undefined && v !== ''
);
if (entries.length === 0) {
return (
<p className="text-sm text-secondary-400 italic">
Keine aktiven Filterkriterien
</p>
);
}
return (
<dl className="space-y-1">
{entries.map(([key, value]) => (
<div key={key} className="flex items-start gap-2 text-sm">
<dt className="font-medium text-secondary-600 min-w-[6rem] capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-secondary-900">
{Array.isArray(value)
? value.join(', ')
: typeof value === 'object'
? JSON.stringify(value)
: String(value)}
</dd>
</div>
))}
</dl>
);
}
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 (
<Modal
open={open}
onClose={onClose}
title={t('savedFilters.saveTitle', 'Filter speichern')}
size="sm"
>
<div className="space-y-4">
{/* Name input */}
<Input
label={t('savedFilters.name', 'Name')}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t(
'savedFilters.namePlaceholder',
'z.B. Wichtige Kunden'
)}
data-testid="filter-name-input"
autoFocus
/>
{/* Criteria summary */}
<div className="rounded-md border border-secondary-200 bg-secondary-50 p-3">
<p className="text-xs font-medium text-secondary-500 uppercase tracking-wide mb-2">
{t('savedFilters.criteriaSummary', 'Aktuelle Filterkriterien')}
</p>
<CriteriaSummary criteria={currentFilters} />
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={onClose}
disabled={submitting}
>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
onClick={handleSave}
disabled={!name.trim() || submitting || !hasCriteria}
isLoading={submitting}
data-testid="save-filter-confirm"
>
{t('common.save', 'Speichern')}
</Button>
</div>
</div>
</Modal>
);
}
@@ -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<string, any>;
onApplyFilter: (criteria: Record<string, any>) => 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<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(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 ( <div className="flex items-center gap-2 flex-wrap" data-testid="saved-filter-bar">
{/* Dropdown trigger */}
<div ref={dropdownRef} className="relative">
<button type="button"
onClick={toggleDropdown} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-secondary-300 bg-white text-sm text-secondary-700 hover:bg-secondary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch" aria-haspopup="listbox"
aria-expanded={dropdownOpen}
aria-label={t('savedFilters.selectFilter', 'Filter auswählen')}
data-testid="saved-filter-dropdown-trigger"
> <Filter className="w-4 h-4 text-secondary-500" aria-hidden="true" />
<span className="max-w-[12rem] truncate">
{activeFilter ? (
<span className="flex items-center gap-1">
<Bookmark className="w-3 h-3 text-primary-500" aria-hidden="true" /> {activeFilter.name} </span>
) : (
t('savedFilters.title', 'Gespeicherte Filter')
)}
</span>
<ChevronDown className={`w-4 h-4 text-secondary-400 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/> </button> {/* Dropdown menu */}
{dropdownOpen && ( <div
className="absolute top-full left-0 mt-1 min-w-[16rem] max-w-[24rem] bg-white border border-secondary-200 rounded-md shadow-lg z-40 max-h-72 overflow-y-auto"
role="listbox"
data-testid="saved-filter-dropdown-menu"
>
{isLoading && (
<div className="px-3 py-2 text-sm text-secondary-400">
{t('common.loading', 'Laden…')}
</div> )}
{!isLoading && savedFilters.length === 0 && (
<div className="px-3 py-2 text-sm text-secondary-400">
{t('savedFilters.empty', 'Keine gespeicherten Filter')}
</div>
)}
{savedFilters.map((filter) => (
<div
key={filter.id}
role="option"
aria-selected={activeFilterId === filter.id}
onClick={() => 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}`}
> <div className="flex items-center gap-2 min-w-0 flex-1">
{activeFilterId === filter.id ? (
<Check className="w-3 h-3 text-primary-600 flex-shrink-0" aria-hidden="true" />
) : (
<Bookmark className="w-3 h-3 text-secondary-400 flex-shrink-0" aria-hidden="true" />
)} <span className="text-sm text-secondary-800 truncate">
{filter.name}
</span> </div>
<button
onClick={(e) => handleDelete(e, filter.id)}
className="opacity-0 group-hover:opacity-100 text-danger-500 hover:text-danger-700 p-1 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-400"
aria-label={t('savedFilters.delete', 'Filter löschen')}
title={t('savedFilters.delete', 'Filter löschen')}
data-testid={`saved-filter-delete-${filter.id}`}
> <Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
))}
</div>
)}
</div> {/* Save current filters button */}
<Button
size="sm"
variant="ghost"
icon={<Save className="w-3.5 h-3.5" />}
onClick={() => setDialogOpen(true)}
data-testid="save-filter-btn"
>
{t('savedFilters.save', 'Speichern')} </Button> {/* Save filter dialog */} <SaveFilterDialog open={dialogOpen}
entityType={entityType}
currentFilters={currentFilters}
onClose={() => setDialogOpen(false)}
/> </div>
);}
+12 -1
View File
@@ -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')}
</button>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
onClick={() => 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"
>
<Code className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
{t('settings.apiDocs', 'API Dokumentation')}
</a>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 text-sm text-danger-600 hover:bg-danger-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500"