mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 14:08:08 +02:00
feat: Introduce SnapshotList component for managing user snapshots and exports, leveraging a new ConfirmModal.
This commit is contained in:
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)" }}
|
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
{/* Accent bar */}
|
{/* 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 */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,6 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { CheckCircle2, X } from "lucide-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 {
|
interface SuccessModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -29,77 +23,66 @@ export function SuccessModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => handleClose(), duration);
|
||||||
handleClose();
|
|
||||||
}, duration);
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isOpen, duration]);
|
}, [isOpen, duration]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
setTimeout(onClose, 300); // Wait for fade out animation
|
setTimeout(onClose, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen && !isVisible) return null;
|
if (!isOpen && !isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"}`}
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-slate-950/40"
|
className="fixed inset-0"
|
||||||
|
style={{ background: "rgba(0,0,0,0.45)" }}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"}`}
|
||||||
"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",
|
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||||
isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<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
|
<button
|
||||||
onClick={handleClose}
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="p-8 flex flex-col items-center text-center">
|
<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">
|
{/* Icon */}
|
||||||
<CheckCircle2 className="w-8 h-8 text-emerald-500" />
|
<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>
|
</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}
|
{title}
|
||||||
</h3>
|
</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}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
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
|
Continuer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SuccessModal } from "@/components/shared/success-modal";
|
import { SuccessModal } from "@/components/shared/success-modal";
|
||||||
|
import { ConfirmModal } from "@/components/shared/confirm-modal";
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("fr-FR", {
|
return new Intl.DateTimeFormat("fr-FR", {
|
||||||
@@ -41,6 +42,14 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
|||||||
message: ""
|
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 restoreSnapshot = useGridStore(state => state.restoreSnapshot);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -60,51 +69,49 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
|||||||
fetchSnapshots();
|
fetchSnapshots();
|
||||||
}, [type]);
|
}, [type]);
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = (id: number) => {
|
||||||
if (!window.confirm(`Supprimer cet ${type === 'export' ? 'export' : 'snapshot'} définitivement ?`)) return;
|
setConfirmModal({
|
||||||
setIsDeleting(id);
|
isOpen: true,
|
||||||
try {
|
title: "Supprimer",
|
||||||
const res = await deleteSnapshot(id);
|
message: `Supprimer cet ${type === 'export' ? 'export' : 'snapshot'} définitivement ?`,
|
||||||
if (res.success) {
|
variant: "danger",
|
||||||
setSnapshots(prev => prev.filter(s => s.id !== id));
|
onConfirm: async () => {
|
||||||
setModal({
|
setIsDeleting(id);
|
||||||
isOpen: true,
|
try {
|
||||||
title: "Action Réussie",
|
const res = await deleteSnapshot(id);
|
||||||
message: "L'élément a été supprimé de votre historique."
|
if (res.success) {
|
||||||
});
|
setSnapshots(prev => prev.filter(s => s.id !== id));
|
||||||
} else {
|
setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." });
|
||||||
setModal({
|
} else {
|
||||||
isOpen: true,
|
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément." });
|
||||||
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 {
|
||||||
} catch (err) {
|
setIsDeleting(null);
|
||||||
setModal({
|
}
|
||||||
isOpen: true,
|
},
|
||||||
title: "Erreur Technique",
|
});
|
||||||
message: "Une erreur est survenue lors de la suppression."
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoad = (snapshot: any) => {
|
const handleLoad = (snapshot: any) => {
|
||||||
if (!window.confirm(`Charger la session "${snapshot.label}" ? Cela remplacera vos brouillons actuels.`)) return;
|
setConfirmModal({
|
||||||
|
isOpen: true,
|
||||||
const changes: Record<string, string> = {};
|
title: "Restaurer la session",
|
||||||
Object.entries(snapshot.changes as any).forEach(([codein, delta]: [string, any]) => {
|
message: `Charger la session "${snapshot.label}" ? Cela remplacera vos brouillons actuels.`,
|
||||||
changes[codein] = delta.after;
|
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) {
|
if (loading) {
|
||||||
@@ -209,6 +216,16 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
|||||||
title={modal.title}
|
title={modal.title}
|
||||||
message={modal.message}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user