2026-07-26 03:08:26 +02:00
|
|
|
/**
|
|
|
|
|
* 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() {
|
2026-08-27 01:43:06 +02:00
|
|
|
const { t } = useTranslation();
|
2026-07-26 03:08:26 +02:00
|
|
|
return (
|
2026-08-27 01:43:06 +02:00
|
|
|
<div className="space-y-4 animate-pulse" aria-label={t('entityHistoryPanel.loadinghistory')}>
|
2026-07-26 03:08:26 +02:00
|
|
|
{[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>
|
|
|
|
|
);
|
|
|
|
|
}
|