Files
leocrm/frontend/src/components/UndoToast.tsx
T
Agent Zero a4d0f0c35d
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(D): Phase D — Undo/Restore komplett implementiert
- D-GEN: RestoreRegistry mit RestoreConfig (model_class, restore_permission, excluded_fields, special_handler)
- D-HOOK: history_hooks.py mit register_history_hooks() für after_create/update/delete
- D-CORE: Company create+update record_history in companies.py
- D-PLUG: Task/Calendar/DMS record_history in services/routes
- D-SOFT: Alle registrierten Entitäten haben deleted_at + un-delete via Registry
- D-MAIL: Mail special_handler (IMAP Trash-Move, Folder-Verify) + record_history in delete/move
- D-TRASH: GET /entity-history/trash (filterbar, paginiert) + Frontend Trash.tsx
- D-TOAST: UndoToast.tsx (5s Auto-Dismiss, useUndoToast Hook)
- D-HIST-UI: HistoryPanel.tsx (Timeline, Diff-View, Restore-Button)
- D-BULK: POST /entity-history/bulk-restore mit partial_success Semantik
- D-RET: POST /entity-history/retention/archive (GDPR hard-delete >90 Tage)
- D-TEST: 26 Tests in test_restore_registry.py, alle grün
- D-DOC: test-strategy.md + security_kernel.md aktualisiert

Backend: 10 Dateien, Frontend: 7 Dateien, Tests: 1 Datei, Docs: 3 Dateien
26/26 Tests passed, TSC 0 errors, App import 492 routes
2026-08-13 23:08:29 +02:00

119 lines
3.3 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Undo2, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useUndoLastAction } from '@/api/entityHistory';
interface UndoToastProps {
message: string;
onUndo: () => void;
onDismiss: () => void;
isUndoing: boolean;
}
/**
* Undo toast shown after delete actions.
* Auto-dismisses after 5 seconds and is manually dismissable.
*/
export function UndoToast({ message, onUndo, onDismiss, isUndoing }: UndoToastProps) {
const { t } = useTranslation();
return (
<div
className="fixed bottom-4 right-4 z-[100] flex items-center gap-3 p-4 rounded-lg shadow-lg border border-secondary-200 bg-white max-w-sm"
role="alert"
aria-live="polite"
data-testid="undo-toast"
>
<p className="flex-1 text-sm font-medium text-secondary-900">{message}</p>
<Button
variant="primary"
size="sm"
onClick={onUndo}
isLoading={isUndoing}
icon={<Undo2 className="w-4 h-4" />}
>
{t('undoToast.undo', 'Undo')}
</Button>
<button
onClick={onDismiss}
className="flex-shrink-0 text-secondary-400 hover:text-secondary-700 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('common.dismiss', 'Schließen')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
}
interface UndoToastState {
message: string;
entityType: string;
entityId: string;
}
/**
* Hook that shows an undo toast after a delete action and provides the undo function.
*
* Returns:
* - `showUndoToast(message, entityType, entityId)`: trigger the toast
* - `undoToast`: the JSX element to render (render it once in the page)
*/
export function useUndoToast() {
const { t } = useTranslation();
const [state, setState] = useState<UndoToastState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const undoMutation = useUndoLastAction();
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const dismiss = useCallback(() => {
clearTimer();
setState(null);
}, [clearTimer]);
const showUndoToast = useCallback(
(message: string, entityType: string, entityId: string) => {
clearTimer();
setState({ message, entityType, entityId });
timerRef.current = setTimeout(() => {
setState(null);
timerRef.current = null;
}, 5000);
},
[clearTimer]
);
const handleUndo = useCallback(async () => {
if (!state) return;
try {
await undoMutation.mutateAsync({
entityType: state.entityType,
entityId: state.entityId,
});
dismiss();
} catch {
// Keep the toast visible so the user can retry; the mutation error is surfaced elsewhere.
}
}, [state, undoMutation, dismiss]);
// Cleanup timer on unmount
useEffect(() => clearTimer, [clearTimer]);
const undoToast = state ? (
<UndoToast
message={state.message}
onUndo={handleUndo}
onDismiss={dismiss}
isUndoing={undoMutation.isPending}
/>
) : null;
return { showUndoToast, undoToast };
}