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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);}
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isToday, isYesterday, parseISO, isValid } from 'date-fns';
|
||||
import { Loader2, Inbox } from 'lucide-react';
|
||||
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
|
||||
import { ActivityFilter, ActivityFilterValues } from '@/components/activity/ActivityFilter';
|
||||
import { useAuditLog, AuditLogEntry } from '@/api/audit';
|
||||
import { formatDateTime, formatDate } from '@/utils/date';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface DayGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
activities: ActivityItem[];
|
||||
}
|
||||
|
||||
function toValidDate(value: string): Date | null {
|
||||
if (!value) return null;
|
||||
const parsed = parseISO(value);
|
||||
return isValid(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getDayLabel(date: Date): string {
|
||||
if (isToday(date)) return 'Heute';
|
||||
if (isYesterday(date)) return 'Gestern';
|
||||
return formatDate(date, 'EEEE, dd.MM.yyyy');
|
||||
}
|
||||
|
||||
function groupByDay(entries: AuditLogEntry[]): DayGroup[] {
|
||||
const groups: Map<string, DayGroup> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const date = toValidDate(entry.timestamp);
|
||||
if (!date) continue;
|
||||
|
||||
const dayKey = formatDate(date, 'yyyy-MM-dd');
|
||||
if (!groups.has(dayKey)) {
|
||||
groups.set(dayKey, {
|
||||
key: dayKey,
|
||||
label: getDayLabel(date),
|
||||
activities: [],
|
||||
});
|
||||
}
|
||||
|
||||
const activity: ActivityItem = {
|
||||
id: `${entry.id}-${entry.timestamp}`,
|
||||
user: entry.user || 'System',
|
||||
action: entry.action || '',
|
||||
time: entry.timestamp ? formatDateTime(entry.timestamp) : '',
|
||||
avatarUrl: null,
|
||||
};
|
||||
groups.get(dayKey)!.activities.push(activity);
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => b.key.localeCompare(a.key));
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4" data-testid="activity-timeline-skeleton">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg shadow-sm border border-secondary-200 p-6">
|
||||
<div className="h-5 w-32 bg-secondary-200 rounded animate-pulse mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 bg-secondary-200 rounded-full animate-pulse" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 w-3/4 bg-secondary-200 rounded animate-pulse" />
|
||||
<div className="h-3 w-1/4 bg-secondary-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm border border-secondary-200 p-12 text-center"
|
||||
data-testid="activity-timeline-empty"
|
||||
>
|
||||
<Inbox className="h-12 w-12 text-secondary-300 mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm text-secondary-500">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityTimelinePage() {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState<ActivityFilterValues>({});
|
||||
const [accumulated, setAccumulated] = useState<AuditLogEntry[]>([]);
|
||||
|
||||
const apiFilters = useMemo(
|
||||
() => ({
|
||||
user: filters.user,
|
||||
action: filters.action,
|
||||
entity: filters.entity_type,
|
||||
dateFrom: filters.date_from,
|
||||
dateTo: filters.date_to,
|
||||
}),
|
||||
[filters]
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useAuditLog(page, PAGE_SIZE, apiFilters);
|
||||
|
||||
const entries = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const hasMore = page * PAGE_SIZE < total;
|
||||
|
||||
useEffect(() => {
|
||||
if (page === 1) {
|
||||
setAccumulated(entries);
|
||||
} else {
|
||||
setAccumulated((prev) => {
|
||||
const existingIds = new Set(prev.map((e) => e.id));
|
||||
const newEntries = entries.filter((e) => !existingIds.has(e.id));
|
||||
return [...prev, ...newEntries];
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entries, page]);
|
||||
|
||||
const handleFilterChange = (newFilters: ActivityFilterValues) => {
|
||||
setFilters(newFilters);
|
||||
setPage(1);
|
||||
setAccumulated([]);
|
||||
};
|
||||
|
||||
const dayGroups = useMemo(() => groupByDay(accumulated), [accumulated]);
|
||||
|
||||
const pageTitle = t('activity.title', 'Aktivitäten');
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="activity-timeline-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{pageTitle}</h1>
|
||||
|
||||
<ActivityFilter onFilter={handleFilterChange} initialValues={filters} />
|
||||
|
||||
{isLoading && page === 1 ? (
|
||||
<LoadingSkeleton />
|
||||
) : isError ? (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm border border-secondary-200 p-12 text-center"
|
||||
data-testid="activity-timeline-error"
|
||||
>
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('activity.loadError', 'Aktivitäten konnten nicht geladen werden.')}
|
||||
</p>
|
||||
</div>
|
||||
) : dayGroups.length === 0 ? (
|
||||
<EmptyState
|
||||
message={t('activity.empty', 'Keine Aktivitäten gefunden.')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{dayGroups.map((group) => (
|
||||
<ActivityFeed
|
||||
key={group.key}
|
||||
activities={group.activities}
|
||||
title={group.label}
|
||||
maxItems={group.activities.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={isFetching}
|
||||
className="inline-flex items-center justify-center font-medium px-4 py-2 rounded-md min-h-touch bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
data-testid="activity-load-more"
|
||||
>
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t('activity.loading', 'Lädt …')}
|
||||
</>
|
||||
) : (
|
||||
t('activity.loadMore', 'Mehr laden')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-secondary-400 mt-4">
|
||||
{accumulated.length} / {total} {t('activity.entriesLoaded', 'Einträge')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Code, BookOpen, FileJson } from 'lucide-react';
|
||||
import { SettingsMenuOrderPage } from './SettingsMenuOrder';
|
||||
import { SettingsThemePage } from './SettingsTheme';
|
||||
import { SettingsPluginsPage } from './SettingsPlugins';
|
||||
@@ -12,6 +13,7 @@ export function SettingsSystemPage() {
|
||||
{ key: 'menu', label: t('settings.menuOrder', 'Menü') },
|
||||
{ key: 'theme', label: t('settings.theme', 'Theme') },
|
||||
{ key: 'plugins', label: t('settings.plugins', 'Plugins') },
|
||||
{ key: 'entwickler', label: t('settings.developer', 'Entwickler') },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -42,6 +44,60 @@ export function SettingsSystemPage() {
|
||||
{activeTab === 'menu' && <SettingsMenuOrderPage />}
|
||||
{activeTab === 'theme' && <SettingsThemePage />}
|
||||
{activeTab === 'plugins' && <SettingsPluginsPage />}
|
||||
{activeTab === 'entwickler' && (
|
||||
<div className="space-y-4" data-testid="settings-developer-section">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-secondary-200 bg-white hover:border-primary-300 hover:shadow-sm transition-all min-h-touch"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 text-primary-600 flex items-center justify-center">
|
||||
<Code className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-secondary-900">
|
||||
{t('settings.apiDocsSwagger', 'API Dokumentation (Swagger)')}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 mt-0.5">/docs</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/redoc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-secondary-200 bg-white hover:border-primary-300 hover:shadow-sm transition-all min-h-touch"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 text-primary-600 flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-secondary-900">
|
||||
{t('settings.apiDocsRedoc', 'API Dokumentation (ReDoc)')}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 mt-0.5">/redoc</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/openapi.json"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-secondary-200 bg-white hover:border-primary-300 hover:shadow-sm transition-all min-h-touch"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 text-primary-600 flex items-center justify-center">
|
||||
<FileJson className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-secondary-900">
|
||||
{t('settings.openApiSchema', 'OpenAPI Schema')}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 mt-0.5">/openapi.json</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ const DedupMergePage = React.lazy(() => import('@/pages/DedupMerge').then(m => (
|
||||
const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m => ({ default: m.ImportExportPage })));
|
||||
const TagsPage = React.lazy(() => import('@/pages/Tags').then(m => ({ default: m.TagsPage })));
|
||||
const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage })));
|
||||
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
@@ -134,6 +135,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/contacts/dedup', element: withSuspense(<DedupMergePage />) },
|
||||
{ path: '/import-export', element: withSuspense(<ImportExportPage />) },
|
||||
{ path: '/tags', element: withSuspense(<TagsPage />) },
|
||||
{ path: '/activity', element: withSuspense(<ActivityTimelinePage />) },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
path: '/settings',
|
||||
|
||||
Reference in New Issue
Block a user