10dcc8ae90
- 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
203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|