Files
leocrm/frontend/src/pages/ActivityTimeline.tsx
T
Agent Zero 10dcc8ae90 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
2026-07-26 03:08:26 +02:00

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>
);
}