a4d0f0c35d
Check Cross-Plugin Imports / check (push) Has been cancelled
- 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
119 lines
3.3 KiB
TypeScript
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 };
|
|
}
|