feat: Introduce SnapshotList component for managing user snapshots and exports, leveraging a new ConfirmModal.

This commit is contained in:
Michael committed 2026-02-22 14:43:48 +01:00
1 parent bab862bf5c
commit 43c625c3ec
4 files changed
+206 -78

No files matched your search

@@ -90,7 +90,7 @@ export function ChangePasswordModal({
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-teal-400 to-emerald-400" />
<div className="absolute top-0 left-0 w-full h-0.5" style={{ background: "linear-gradient(to right, var(--accent), var(--accent-success))" }} />
{/* Close button */}
<button
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { X, AlertTriangle } from "lucide-react";
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title = "Confirmation",
message,
confirmLabel = "OK",
cancelLabel = "Annuler",
variant = "default",
}: ConfirmModalProps) {
const [isVisible, setIsVisible] = useState(false);
const confirmBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
setTimeout(() => confirmBtnRef.current?.focus(), 50);
} else {
setIsVisible(false);
}
}, [isOpen]);
const handleClose = () => {
setIsVisible(false);
setTimeout(onClose, 250);
};
const handleConfirm = () => {
setIsVisible(false);
setTimeout(onConfirm, 250);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") handleClose();
if (e.key === "Enter") handleConfirm();
};
if (!isOpen && !isVisible) return null;
const isDanger = variant === "danger";
return (
<div
className={`fixed inset-0 z-[200] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
onKeyDown={handleKeyDown}
>
{/* Backdrop */}
<div
className="fixed inset-0"
style={{ background: "rgba(0,0,0,0.55)" }}
onClick={handleClose}
/>
{/* Panel */}
<div
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
>
{/* Accent bar */}
<div
className="absolute top-0 left-0 w-full h-0.5"
style={{
background: isDanger
? "linear-gradient(to right, var(--accent-error), #fb7185)"
: "linear-gradient(to right, var(--accent), var(--accent-success))"
}}
/>
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
>
<X className="w-4 h-4" />
</button>
<div className="p-7">
{/* Icon + Title */}
<div className="flex items-center gap-3 mb-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${isDanger ? "bg-[var(--accent-error-bg)] border border-[var(--accent-error)]" : "bg-[var(--accent-bg)] border border-[var(--accent-border)]"}`}>
<AlertTriangle className={`w-5 h-5 ${isDanger ? "text-[var(--accent-error)]" : "text-[var(--accent)]"}`} />
</div>
<h3 className="text-base font-bold text-[var(--text-primary)]">{title}</h3>
</div>
{/* Message */}
<p className="text-sm text-[var(--text-secondary)] mb-7 leading-relaxed pl-[52px]">
{message}
</p>
{/* Actions */}
<div className="flex gap-3 justify-end">
<button
onClick={handleClose}
className="apple-btn-secondary"
>
{cancelLabel}
</button>
<button
ref={confirmBtnRef}
onClick={handleConfirm}
className={`apple-btn-primary ${isDanger ? "!bg-[var(--accent-error)] hover:opacity-90" : ""}`}
style={isDanger ? { background: "var(--accent-error)" } : undefined}
>
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
);
}
+19 -36
View File
@@ -2,12 +2,6 @@
import React, { useEffect, useState } from "react";
import { CheckCircle2, X } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface SuccessModalProps {
isOpen: boolean;
@@ -29,77 +23,66 @@ export function SuccessModal({
useEffect(() => {
if (isOpen) {
setIsVisible(true);
const timer = setTimeout(() => {
handleClose();
}, duration);
const timer = setTimeout(() => handleClose(), duration);
return () => clearTimeout(timer);
}
}, [isOpen, duration]);
const handleClose = () => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for fade out animation
setTimeout(onClose, 250);
};
if (!isOpen && !isVisible) return null;
return (
<div
className={cn(
"fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-300",
isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0 backdrop-blur-0"
)}
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-slate-950/40"
className="fixed inset-0"
style={{ background: "rgba(0,0,0,0.45)" }}
onClick={handleClose}
/>
{/* Panel */}
<div
className={cn(
"relative w-full max-w-sm overflow-hidden rounded-3xl border border-slate-200/50 dark:border-slate-800/50 bg-white dark:bg-slate-900 shadow-2xl transition-all duration-300 transform",
isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"
)}
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
>
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-emerald-400 to-teal-500" />
{/* Accent bar */}
<div className="absolute top-0 left-0 w-full h-0.5" style={{ background: "linear-gradient(to right, var(--accent), var(--accent-success))" }} />
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 right-4 p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 transition-colors"
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
>
<X className="w-4 h-4" />
</button>
<div className="p-8 flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mb-6 animate-bounce-slow">
<CheckCircle2 className="w-8 h-8 text-emerald-500" />
{/* Icon */}
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center mb-5">
<CheckCircle2 className="w-8 h-8 text-[var(--accent)]" />
</div>
<h3 className="text-xl font-black text-slate-900 dark:text-slate-100 mb-2 tracking-tight">
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2 tracking-tight">
{title}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium leading-relaxed">
<p className="text-sm text-[var(--text-secondary)] font-medium leading-relaxed">
{message}
</p>
<button
onClick={handleClose}
className="mt-8 w-full py-3 rounded-2xl bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 font-bold text-sm transition-all active:scale-95 hover:brightness-110 shadow-lg shadow-slate-900/10"
className="apple-btn-primary mt-7 w-full justify-center"
>
Continuer
</button>
</div>
</div>
<style jsx global>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.animate-bounce-slow {
animation: bounce-slow 2s ease-in-out infinite;
}
`}</style>
</div>
);
}
@@ -15,6 +15,7 @@ import {
Loader2,
} from "lucide-react";
import { SuccessModal } from "@/components/shared/success-modal";
import { ConfirmModal } from "@/components/shared/confirm-modal";
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("fr-FR", {
@@ -41,6 +42,14 @@ export function SnapshotList({ type }: SnapshotListProps) {
message: ""
});
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
variant?: "danger" | "default";
onConfirm: () => void;
}>({ isOpen: false, title: "", message: "", onConfirm: () => { } });
const restoreSnapshot = useGridStore(state => state.restoreSnapshot);
const router = useRouter();
@@ -60,51 +69,49 @@ export function SnapshotList({ type }: SnapshotListProps) {
fetchSnapshots();
}, [type]);
const handleDelete = async (id: number) => {
if (!window.confirm(`Supprimer cet ${type === 'export' ? 'export' : 'snapshot'} définitivement ?`)) return;
setIsDeleting(id);
try {
const res = await deleteSnapshot(id);
if (res.success) {
setSnapshots(prev => prev.filter(s => s.id !== id));
setModal({
isOpen: true,
title: "Action Réussie",
message: "L'élément a été supprimé de votre historique."
});
} else {
setModal({
isOpen: true,
title: "Erreur",
message: "Impossible de supprimer l'élément."
});
}
} catch (err) {
setModal({
isOpen: true,
title: "Erreur Technique",
message: "Une erreur est survenue lors de la suppression."
});
} finally {
setIsDeleting(null);
}
const handleDelete = (id: number) => {
setConfirmModal({
isOpen: true,
title: "Supprimer",
message: `Supprimer cet ${type === 'export' ? 'export' : 'snapshot'} définitivement ?`,
variant: "danger",
onConfirm: async () => {
setIsDeleting(id);
try {
const res = await deleteSnapshot(id);
if (res.success) {
setSnapshots(prev => prev.filter(s => s.id !== id));
setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." });
} else {
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément." });
}
} catch (err) {
setModal({ isOpen: true, title: "Erreur Technique", message: "Une erreur est survenue lors de la suppression." });
} finally {
setIsDeleting(null);
}
},
});
};
const handleLoad = (snapshot: any) => {
if (!window.confirm(`Charger la session "${snapshot.label}" ? Cela remplacera vos brouillons actuels.`)) return;
const changes: Record<string, string> = {};
Object.entries(snapshot.changes as any).forEach(([codein, delta]: [string, any]) => {
changes[codein] = delta.after;
setConfirmModal({
isOpen: true,
title: "Restaurer la session",
message: `Charger la session "${snapshot.label}" ? Cela remplacera vos brouillons actuels.`,
variant: "default",
onConfirm: () => {
const changes: Record<string, string> = {};
Object.entries(snapshot.changes as any).forEach(([codein, delta]: [string, any]) => {
changes[codein] = delta.after;
});
restoreSnapshot(changes);
const query = new URLSearchParams();
if (snapshot.codeFournisseur) query.set("fournisseur", snapshot.codeFournisseur);
if (snapshot.magasin) query.set("magasin", snapshot.magasin);
router.push(`/grid?${query.toString()}`);
},
});
restoreSnapshot(changes);
const query = new URLSearchParams();
if (snapshot.codeFournisseur) query.set("fournisseur", snapshot.codeFournisseur);
if (snapshot.magasin) query.set("magasin", snapshot.magasin);
router.push(`/grid?${query.toString()}`);
};
if (loading) {
@@ -209,6 +216,16 @@ export function SnapshotList({ type }: SnapshotListProps) {
title={modal.title}
message={modal.message}
/>
<ConfirmModal
isOpen={confirmModal.isOpen}
onClose={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={confirmModal.onConfirm}
title={confirmModal.title}
message={confirmModal.message}
variant={confirmModal.variant}
confirmLabel={confirmModal.variant === "danger" ? "Supprimer" : "Confirmer"}
/>
</div>
);
}