Files
2026-01-02 17:52:07 +01:00

946 lines
36 KiB
TypeScript

"use client";
import { CATEGORIES, STATUSES } from "@/lib/constants";
import { KanbanView, Task, TaskCategory, TaskStatus } from "@/types";
import {
closestCorners,
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useState } from "react";
import Confetti from "./Confetti";
import ConfirmModal from "./ConfirmModal";
import TaskDetailModal from "./TaskDetailModal";
import Toast, { ToastData } from "./Toast";
interface KanbanBoardProps {
onBack: () => void;
}
export default function KanbanBoard({ onBack }: KanbanBoardProps) {
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<KanbanView>("category");
const [addingTask, setAddingTask] = useState(false);
const [newTask, setNewTask] = useState({
text: "",
category: "urgent" as TaskCategory,
});
const [loading, setLoading] = useState(true);
const [showConfetti, setShowConfetti] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [originalTask, setOriginalTask] = useState<Task | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [toasts, setToasts] = useState<ToastData[]>([]);
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}>({ isOpen: false, title: "", message: "", onConfirm: () => {} });
const addToast = useCallback(
(message: string, type: ToastData["type"] = "success") => {
const id = Date.now().toString();
setToasts((prev) => [...prev, { id, message, type }]);
},
[]
);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
useEffect(() => {
fetchTasks();
}, []);
const fetchTasks = async () => {
try {
const response = await fetch("/api/tasks");
const data = await response.json();
setTasks(data);
} catch (error) {
console.error("Error fetching tasks:", error);
} finally {
setLoading(false);
}
};
const addTask = async () => {
if (!newTask.text.trim()) return;
try {
const response = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTask),
});
if (response.ok) {
const createdTask = await response.json();
setTasks([createdTask, ...tasks]);
setNewTask({ text: "", category: "urgent" });
setAddingTask(false);
addToast("Tâche créée", "success");
}
} catch (error) {
console.error("Error adding task:", error);
addToast("Erreur lors de la création", "error");
}
};
const updateTaskStatus = async (taskId: number, status: TaskStatus) => {
try {
const response = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, status }),
});
if (response.ok) {
const updatedTask = await response.json();
setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t)));
}
} catch (error) {
console.error("Error updating task:", error);
}
};
const updateTaskCategory = async (
taskId: number,
category: TaskCategory
) => {
console.log("updateTaskCategory called:", { taskId, category });
try {
const response = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, category }),
});
console.log("Response status:", response.status);
if (response.ok) {
const updatedTask = await response.json();
console.log("Updated task:", updatedTask);
setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t)));
} else {
const error = await response.json();
console.error("API Error:", error);
}
} catch (error) {
console.error("Error updating task:", error);
}
};
const completeAndArchiveTask = async (taskId: number) => {
try {
const response = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, status: "archived" }),
});
if (response.ok) {
setShowConfetti(true);
setTimeout(() => setShowConfetti(false), 3000);
const updatedTask = await response.json();
setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t)));
addToast("Tâche archivée 🎉", "success");
}
} catch (error) {
console.error("Error archiving task:", error);
addToast("Erreur lors de l'archivage", "error");
}
};
const restoreTask = async (taskId: number) => {
try {
const response = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, status: "todo" }),
});
if (response.ok) {
const updatedTask = await response.json();
setTasks(tasks.map((t) => (t.id === taskId ? updatedTask : t)));
addToast("Tâche restaurée", "info");
}
} catch (error) {
console.error("Error restoring task:", error);
addToast("Erreur lors de la restauration", "error");
}
};
const deleteTask = async (taskId: number) => {
setConfirmModal({
isOpen: true,
title: "Supprimer la tâche",
message:
"Es-tu sûr de vouloir supprimer cette tâche ? Cette action est irréversible.",
onConfirm: async () => {
try {
const response = await fetch(`/api/tasks?id=${taskId}`, {
method: "DELETE",
});
if (response.ok) {
setTasks(tasks.filter((t) => t.id !== taskId));
addToast("Tâche supprimée", "success");
}
} catch (error) {
console.error("Error deleting task:", error);
addToast("Erreur lors de la suppression", "error");
}
setConfirmModal((prev) => ({ ...prev, isOpen: false }));
},
});
};
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const task = tasks.find((t) => t.id === active.id);
if (task) {
setActiveTask(task);
setOriginalTask({ ...task });
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id as number;
const overId = over.id;
if (view === "category") {
const categories = CATEGORIES.map((c) => c.key);
let targetCategory: TaskCategory | null = null;
if (categories.includes(overId as TaskCategory)) {
targetCategory = overId as TaskCategory;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (overTask) {
targetCategory = overTask.category;
}
}
if (targetCategory) {
const activeTask = tasks.find((t) => t.id === activeId);
if (activeTask && activeTask.category !== targetCategory) {
setTasks(
tasks.map((t) =>
t.id === activeId
? {
...t,
category: targetCategory as TaskCategory,
}
: t
)
);
}
}
} else {
const statuses = STATUSES.map((s) => s.key);
let targetStatus: TaskStatus | null = null;
if (statuses.includes(overId as TaskStatus)) {
targetStatus = overId as TaskStatus;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (overTask) {
targetStatus = overTask.status;
}
}
if (targetStatus) {
const activeTask = tasks.find((t) => t.id === activeId);
if (activeTask && activeTask.status !== targetStatus) {
setTasks(
tasks.map((t) =>
t.id === activeId
? { ...t, status: targetStatus as TaskStatus }
: t
)
);
}
}
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
console.log("handleDragEnd:", {
activeId: active.id,
overId: over?.id,
originalTask,
});
setActiveTask(null);
if (!over || !originalTask) {
console.log("No over or originalTask, aborting");
setOriginalTask(null);
return;
}
const activeId = active.id as number;
const overId = over.id;
if (view === "category") {
const categories = CATEGORIES.map((c) => c.key);
let targetCategory: TaskCategory | null = null;
if (categories.includes(overId as TaskCategory)) {
targetCategory = overId as TaskCategory;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (overTask) {
targetCategory = overTask.category;
}
}
console.log(
"Target category:",
targetCategory,
"Original:",
originalTask.category
);
if (targetCategory && originalTask.category !== targetCategory) {
console.log(
"Calling updateTaskCategory:",
activeId,
targetCategory
);
await updateTaskCategory(activeId, targetCategory);
}
} else {
const statuses = STATUSES.map((s) => s.key);
let targetStatus: TaskStatus | null = null;
if (statuses.includes(overId as TaskStatus)) {
targetStatus = overId as TaskStatus;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (overTask) {
targetStatus = overTask.status;
}
}
if (targetStatus) {
if (targetStatus === "done") {
await completeAndArchiveTask(activeId);
} else if (originalTask.status !== targetStatus) {
await updateTaskStatus(activeId, targetStatus);
}
}
}
setOriginalTask(null);
};
const activeTasks = tasks.filter((t) => t.status !== "archived");
const archivedTasks = tasks.filter((t) => t.status === "archived");
const getTasksByCategory = (category: TaskCategory) =>
activeTasks.filter((t) => t.category === category);
const getTasksByStatus = (status: TaskStatus) =>
activeTasks.filter((t) => t.status === status);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-2xl">Chargement...</div>
</div>
);
}
return (
<>
<Confetti show={showConfetti} />
<div className="min-h-screen p-4 md:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onBack}
className="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-xl transition-colors"
>
Retour
</motion.button>
<h1 className="text-4xl font-bold">Ton Kanban</h1>
<div className="flex gap-3 flex-wrap justify-center">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() =>
setView(
view === "category"
? "status"
: "category"
)
}
className="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-xl transition-colors"
>
Vue:{" "}
{view === "category" ? "Catégorie" : "Statut"}
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setShowArchived(!showArchived)}
className={`px-6 py-3 rounded-xl transition-colors ${
showArchived
? "bg-slate-600 hover:bg-slate-500"
: "bg-gray-700 hover:bg-gray-600"
}`}
>
📦 Archivées ({archivedTasks.length})
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setAddingTask(true)}
className="bg-blue-500 hover:bg-blue-600 px-6 py-3 rounded-xl transition-colors"
>
+ Ajouter
</motion.button>
</div>
</div>
<div className="text-center text-gray-500 text-sm mb-4">
💡 Glisse-dépose les tâches entre les colonnes
</div>
<AnimatePresence>
{showArchived && archivedTasks.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mb-8 overflow-hidden"
>
<div className="bg-slate-800/50 rounded-2xl p-6">
<h2 className="text-xl font-bold mb-4 text-slate-300">
📦 Tâches archivées
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{archivedTasks.map((task) => {
const cat = CATEGORIES.find(
(c) => c.key === task.category
);
return (
<motion.div
key={task.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={`bg-slate-700/50 rounded-xl p-4 border-l-4 ${cat?.border}`}
>
<p className="text-slate-300 line-through mb-2">
{task.text}
</p>
<p className="text-xs text-slate-500 mb-3">
{cat?.label} {" "}
{task.added}
</p>
<div className="flex gap-2">
<button
onClick={() =>
restoreTask(
task.id
)
}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white py-1 px-2 text-xs rounded transition"
>
Restaurer
</button>
<button
onClick={() =>
deleteTask(
task.id
)
}
className="flex-1 bg-red-900 hover:bg-red-800 text-red-200 py-1 px-2 text-xs rounded transition"
>
Supprimer
</button>
</div>
</motion.div>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{view === "category" ? (
<CategoryView
tasks={activeTasks}
getTasksByCategory={getTasksByCategory}
updateTaskStatus={updateTaskStatus}
completeAndArchiveTask={completeAndArchiveTask}
deleteTask={deleteTask}
onOpenDetail={setSelectedTask}
/>
) : (
<StatusView
tasks={activeTasks}
getTasksByStatus={getTasksByStatus}
updateTaskStatus={updateTaskStatus}
completeAndArchiveTask={completeAndArchiveTask}
deleteTask={deleteTask}
onOpenDetail={setSelectedTask}
/>
)}
<DragOverlay>
{activeTask ? (
<TaskCardOverlay task={activeTask} />
) : null}
</DragOverlay>
</DndContext>
{addingTask && (
<AddTaskModal
newTask={newTask}
setNewTask={setNewTask}
onAdd={addTask}
onCancel={() => {
setAddingTask(false);
setNewTask({ text: "", category: "urgent" });
}}
/>
)}
<AnimatePresence>
{selectedTask && (
<TaskDetailModal
task={selectedTask}
onClose={() => setSelectedTask(null)}
onUpdate={(updatedTask) => {
setTasks(
tasks.map((t) =>
t.id === updatedTask.id
? updatedTask
: t
)
);
setSelectedTask(updatedTask);
}}
onDelete={(taskId) => {
deleteTask(taskId);
setSelectedTask(null);
}}
onComplete={(taskId) => {
completeAndArchiveTask(taskId);
setSelectedTask(null);
}}
/>
)}
</AnimatePresence>
<Toast toasts={toasts} removeToast={removeToast} />
<ConfirmModal
isOpen={confirmModal.isOpen}
title={confirmModal.title}
message={confirmModal.message}
confirmText="Supprimer"
cancelText="Annuler"
confirmColor="red"
onConfirm={confirmModal.onConfirm}
onCancel={() =>
setConfirmModal((prev) => ({
...prev,
isOpen: false,
}))
}
/>
</div>
</div>
</>
);
}
function DroppableColumn({
id,
children,
color,
label,
count,
}: {
id: string;
children: React.ReactNode;
color: string;
label: string;
count: number;
}) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef}>
<div
className={`${color} rounded-xl p-3 mb-4 text-center font-bold transition-all ${
isOver ? "ring-2 ring-white ring-opacity-50 scale-105" : ""
}`}
>
{label} ({count})
</div>
<div
className={`space-y-3 min-h-25 rounded-xl p-2 transition-all ${
isOver ? "bg-gray-700/30" : ""
}`}
>
{children}
</div>
</div>
);
}
function SortableTaskCard({
task,
onUpdateStatus,
onComplete,
onDelete,
onOpenDetail,
}: {
task: Task;
onUpdateStatus: (taskId: number, status: TaskStatus) => void;
onComplete: (taskId: number) => void;
onDelete: (taskId: number) => void;
onOpenDetail: (task: Task) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const cat = CATEGORIES.find((c) => c.key === task.category);
const checklistCount = task.checklist?.length || 0;
const checklistDone = task.checklist?.filter((i) => i.checked).length || 0;
return (
<div ref={setNodeRef} style={style} {...attributes}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`bg-gray-800 rounded-xl p-4 border-l-4 ${
cat?.border
} ${
isDragging ? "shadow-2xl" : ""
} cursor-pointer hover:bg-gray-750 transition`}
onClick={() => onOpenDetail(task)}
>
<div
{...listeners}
onClick={(e) => e.stopPropagation()}
className="cursor-grab active:cursor-grabbing mb-2 text-gray-500 hover:text-gray-300 flex items-center gap-2"
>
<span className="text-lg"></span>
<span className="text-xs">Glisser</span>
</div>
<p className="mb-2 font-medium">{task.text}</p>
<div className="flex gap-2 mb-3 flex-wrap">
{task.description && (
<span className="text-xs bg-gray-700 px-2 py-1 rounded text-gray-400">
📝 Description
</span>
)}
{checklistCount > 0 && (
<span
className={`text-xs px-2 py-1 rounded ${
checklistDone === checklistCount
? "bg-green-500/20 text-green-400"
: "bg-gray-700 text-gray-400"
}`}
>
☑️ {checklistDone}/{checklistCount}
</span>
)}
</div>
<div
className="flex gap-1 mb-2"
onClick={(e) => e.stopPropagation()}
>
{STATUSES.map((status) => (
<button
key={status.key}
onClick={() => {
if (status.key === "done") {
onComplete(task.id);
} else {
onUpdateStatus(task.id, status.key);
}
}}
className={`flex-1 py-1 text-xs rounded transition ${
task.status === status.key
? `${status.color} text-white`
: "bg-gray-700 hover:bg-gray-600"
}`}
>
{status.key === "done" ? "✓ " : ""}
{status.label}
</button>
))}
</div>
<p className="text-xs text-gray-500 mb-2">{task.added}</p>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(task.id);
}}
className="w-full bg-red-900 hover:bg-red-800 text-red-200 py-1 text-xs rounded transition"
>
Supprimer
</button>
</motion.div>
</div>
);
}
function TaskCardOverlay({ task }: { task: Task }) {
const cat = CATEGORIES.find((c) => c.key === task.category);
return (
<div
className={`bg-gray-800 rounded-xl p-4 border-l-4 ${cat?.border} shadow-2xl rotate-3 opacity-90`}
>
<p className="mb-3">{task.text}</p>
<p className="text-xs text-gray-500">{task.added}</p>
</div>
);
}
function CategoryView({
tasks,
getTasksByCategory,
updateTaskStatus,
completeAndArchiveTask,
deleteTask,
onOpenDetail,
}: {
tasks: Task[];
getTasksByCategory: (category: TaskCategory) => Task[];
updateTaskStatus: (taskId: number, status: TaskStatus) => void;
completeAndArchiveTask: (taskId: number) => void;
deleteTask: (taskId: number) => void;
onOpenDetail: (task: Task) => void;
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{CATEGORIES.map((cat) => {
const categoryTasks = getTasksByCategory(cat.key);
return (
<SortableContext
key={cat.key}
items={categoryTasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<DroppableColumn
id={cat.key}
color={cat.color}
label={cat.label}
count={categoryTasks.length}
>
{categoryTasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
onUpdateStatus={updateTaskStatus}
onComplete={completeAndArchiveTask}
onDelete={deleteTask}
onOpenDetail={onOpenDetail}
/>
))}
</DroppableColumn>
</SortableContext>
);
})}
</div>
);
}
function StatusView({
tasks,
getTasksByStatus,
updateTaskStatus,
completeAndArchiveTask,
deleteTask,
onOpenDetail,
}: {
tasks: Task[];
getTasksByStatus: (status: TaskStatus) => Task[];
updateTaskStatus: (taskId: number, status: TaskStatus) => void;
completeAndArchiveTask: (taskId: number) => void;
deleteTask: (taskId: number) => void;
onOpenDetail: (task: Task) => void;
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{STATUSES.map((status) => {
const statusTasks = getTasksByStatus(status.key);
return (
<SortableContext
key={status.key}
items={statusTasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<DroppableColumn
id={status.key}
color={status.color}
label={status.label}
count={statusTasks.length}
>
{statusTasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
onUpdateStatus={updateTaskStatus}
onComplete={completeAndArchiveTask}
onDelete={deleteTask}
onOpenDetail={onOpenDetail}
/>
))}
</DroppableColumn>
</SortableContext>
);
})}
</div>
);
}
function AddTaskModal({
newTask,
setNewTask,
onAdd,
onCancel,
}: {
newTask: { text: string; category: TaskCategory };
setNewTask: (task: { text: string; category: TaskCategory }) => void;
onAdd: () => void;
onCancel: () => void;
}) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4"
onClick={onCancel}
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
onClick={(e) => e.stopPropagation()}
className="bg-gray-800 rounded-2xl p-6 w-full max-w-md"
>
<h2 className="text-2xl font-bold mb-4">Nouvelle tâche</h2>
<textarea
value={newTask.text}
onChange={(e) =>
setNewTask({ ...newTask, text: e.target.value })
}
placeholder="Décris ta tâche..."
className="w-full bg-gray-700 text-white p-3 rounded-xl outline-none resize-none mb-4"
rows={4}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey) {
onAdd();
}
}}
/>
<p className="text-sm text-gray-400 mb-2">Catégorie:</p>
<div className="grid grid-cols-2 gap-2 mb-4">
{CATEGORIES.map((cat) => (
<button
key={cat.key}
onClick={() =>
setNewTask({ ...newTask, category: cat.key })
}
className={`px-3 py-2 rounded-xl transition ${
newTask.category === cat.key
? `${cat.color} text-white`
: "bg-gray-700 hover:bg-gray-600"
}`}
>
{newTask.category === cat.key ? "[X]" : "[ ]"}{" "}
{cat.label}
</button>
))}
</div>
<div className="flex gap-3">
<button
onClick={onAdd}
className="flex-1 bg-green-500 hover:bg-green-600 py-2 rounded-xl transition font-bold"
>
Ajouter
</button>
<button
onClick={onCancel}
className="flex-1 bg-gray-700 hover:bg-gray-600 py-2 rounded-xl transition"
>
Annuler
</button>
</div>
</motion.div>
</motion.div>
);
}