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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user