From 2b09dd40936486870a305527f23a3fc145ee58e2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Tue, 24 Feb 2026 10:07:47 +0100 Subject: [PATCH] Add due_date field to tasks and implement roadmap feature - Add due_date column to tasks table with Supabase migration - Update database types and tasks_with_assignee view - Add DatePicker to TaskModal for setting due dates - Display due dates on KanbanTaskCard, list view, and Etapes section - Enable Roadmap tab on both Tasks page and Tablo Details page - Add RoadmapView components with timeline grouped by Etape - Highlight overdue dates in red across all views Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/kanban/KanbanTaskCard.tsx | 29 +- apps/main/src/components/kanban/TaskModal.tsx | 43 +- apps/main/src/hooks/tasks.ts | 16 +- apps/main/src/pages/tablo-details.tsx | 546 +++++++++++++---- apps/main/src/pages/tasks.tsx | 568 ++++++++++++++---- packages/shared-types/src/database.types.ts | 4 + .../20260224000000_add_due_date_to_tasks.sql | 32 + 7 files changed, 994 insertions(+), 244 deletions(-) create mode 100644 supabase/migrations/20260224000000_add_due_date_to_tasks.sql diff --git a/apps/main/src/components/kanban/KanbanTaskCard.tsx b/apps/main/src/components/kanban/KanbanTaskCard.tsx index fdd9303..ba58c74 100644 --- a/apps/main/src/components/kanban/KanbanTaskCard.tsx +++ b/apps/main/src/components/kanban/KanbanTaskCard.tsx @@ -1,6 +1,6 @@ import type { KanbanTask } from "@xtablo/shared-types"; import { TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography"; -import { User } from "lucide-react"; +import { CalendarIcon, User } from "lucide-react"; interface KanbanTaskCardProps { task: KanbanTask; @@ -8,7 +8,24 @@ interface KanbanTaskCardProps { onClick: () => void; } +function formatDueDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + }); +} + +function isOverdue(dateStr: string): boolean { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const due = new Date(dateStr); + return due < today; +} + export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => { + const overdue = task.due_date && task.status !== "done" && isOverdue(task.due_date); + return (
)} + {/* Due date */} + {task.due_date && ( +
+ + {formatDueDate(task.due_date)} +
+ )} + {/* Status Pill */}
("unassigned"); const [etapeId, setEtapeId] = useState("none"); + const [dueDate, setDueDate] = useState(undefined); const [selectedTabloId, setSelectedTabloId] = useState( initialTabloId || tablos?.[0]?.id || "" ); @@ -68,6 +70,7 @@ export const TaskModal = ({ setDescription(task.description ?? ""); setAssigneeId(task.assignee_id ?? "unassigned"); setEtapeId(task.parent_task_id ?? "none"); + setDueDate(task.due_date ? new Date(task.due_date) : undefined); if (!initialTabloId && task.tablo_id) { setSelectedTabloId(task.tablo_id); } @@ -77,6 +80,7 @@ export const TaskModal = ({ setDescription(""); setAssigneeId("unassigned"); setEtapeId("none"); + setDueDate(undefined); if (allowTabloSelection && tablos && tablos.length > 0) { setSelectedTabloId(tablos[0].id); } @@ -86,11 +90,22 @@ export const TaskModal = ({ const { mutate: createTask } = useCreateTask(); const { mutate: updateTask } = useUpdateTask(); + // Format Date to YYYY-MM-DD string for database storage + const formatDateForDb = (date: Date | undefined): string | null => { + if (!date) return null; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) return; if (!currentTabloId) return; // Need a tablo to create task + const dueDateValue = formatDateForDb(dueDate); + if (taskId && task) { updateTask({ tablo_id: task.tablo_id, @@ -100,6 +115,7 @@ export const TaskModal = ({ assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined, status: initialStatus, parent_task_id: etapeId !== "none" ? etapeId : null, + due_date: dueDateValue, }); } else { createTask({ @@ -109,6 +125,7 @@ export const TaskModal = ({ assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined, status: initialStatus, parent_task_id: etapeId !== "none" ? etapeId : null, + due_date: dueDateValue, }); } // Reset form @@ -116,6 +133,7 @@ export const TaskModal = ({ setDescription(""); setAssigneeId("unassigned"); setEtapeId("none"); + setDueDate(undefined); onClose(); }; @@ -188,22 +206,11 @@ export const TaskModal = ({ />
- {/* Type */} - {/*
- - -
*/} + {/* Due Date */} +
+ + +
{/* Assignee */}
diff --git a/apps/main/src/hooks/tasks.ts b/apps/main/src/hooks/tasks.ts index 63cc8c7..5f7bf6e 100644 --- a/apps/main/src/hooks/tasks.ts +++ b/apps/main/src/hooks/tasks.ts @@ -15,6 +15,7 @@ type CreateEtapeInput = { title: string; description?: string | null; position?: number; + due_date?: string | null; }; type UpdateEtapeInput = { @@ -23,6 +24,7 @@ type UpdateEtapeInput = { title?: string; description?: string | null; position?: number; + due_date?: string | null; }; type DeleteEtapeInput = { @@ -169,6 +171,7 @@ export const useCreateTask = () => { position: task.position || 0, parent_task_id: task.parent_task_id ?? null, is_parent: task.is_parent ?? false, + due_date: task.due_date ?? null, }) .select() .single(); @@ -358,7 +361,7 @@ export const useCreateEtape = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ tabloId, title, description, position }: CreateEtapeInput) => { + mutationFn: async ({ tabloId, title, description, position, due_date }: CreateEtapeInput) => { const { data, error } = await supabase .from("tasks") .insert({ @@ -368,6 +371,7 @@ export const useCreateEtape = () => { status: "todo", position: position ?? 0, is_parent: true, + due_date: due_date ?? null, }) .select() .single(); @@ -404,11 +408,19 @@ export const useUpdateEtape = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ id, tabloId, title, description, position }: UpdateEtapeInput) => { + mutationFn: async ({ + id, + tabloId, + title, + description, + position, + due_date, + }: UpdateEtapeInput) => { const updates: Record = {}; if (title !== undefined) updates.title = title; if (description !== undefined) updates.description = description; if (position !== undefined) updates.position = position; + if (due_date !== undefined) updates.due_date = due_date; if (Object.keys(updates).length === 0) { throw new Error("Aucune modification fournie pour l'Étape"); diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index ced2221..1694bf0 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -1,7 +1,7 @@ +import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import { cn, toast } from "@xtablo/shared"; import type { UserTablo } from "@xtablo/shared/types/tablos.types"; import type { Etape, KanbanTask } from "@xtablo/shared-types"; -import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar"; import { Button } from "@xtablo/ui/components/button"; import { @@ -39,39 +39,45 @@ import { Zap, } from "lucide-react"; import { useEffect, useState } from "react"; -import { - Link, - useNavigate, - useParams, - useSearchParams, -} from "react-router-dom"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { TaskModal } from "../components/kanban/TaskModal"; import { TabloDiscussionSection } from "../components/TabloDiscussionSection"; import { TabloEventsSection } from "../components/TabloEventsSection"; import { TabloFilesSection } from "../components/TabloFilesSection"; import { TabloTasksSection } from "../components/TabloTasksSection"; import { useInviteUser } from "../hooks/invite"; -import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; -import { useAllTasks, useCreateTask, useTabloEtapes } from "../hooks/tasks"; import { useTabloFileNames } from "../hooks/tablo_data"; -import { useTablosList, useTabloMembers } from "../hooks/tablos"; +import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; +import { useTabloMembers, useTablosList } from "../hooks/tablos"; +import { useAllTasks, useCreateTask, useTabloEtapes } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; // ─── Icon helpers ───────────────────────────────────────────────────────────── function getTabloIcon(color: string | null | undefined) { switch (color) { - case "bg-blue-500": return Zap; - case "bg-green-500": return Leaf; - case "bg-purple-500": return Gem; - case "bg-red-500": return Flame; - case "bg-yellow-500": return Star; - case "bg-indigo-500": return Compass; - case "bg-pink-500": return Heart; - case "bg-teal-500": return Waves; - case "bg-orange-500": return Sun; - case "bg-cyan-500": return Sparkles; - default: return FolderIcon; + case "bg-blue-500": + return Zap; + case "bg-green-500": + return Leaf; + case "bg-purple-500": + return Gem; + case "bg-red-500": + return Flame; + case "bg-yellow-500": + return Star; + case "bg-indigo-500": + return Compass; + case "bg-pink-500": + return Heart; + case "bg-teal-500": + return Waves; + case "bg-orange-500": + return Sun; + case "bg-cyan-500": + return Sparkles; + default: + return FolderIcon; } } @@ -139,7 +145,7 @@ const TABS: { { id: "files", label: "Fichiers", icon: FolderIcon }, { id: "discussion", label: "Discussion", icon: MessageCircleIcon }, { id: "events", label: "Événements", icon: CalendarIcon }, - { id: "roadmap", label: "Roadmap", icon: MapIcon, disabled: true }, + { id: "roadmap", label: "Roadmap", icon: MapIcon }, ]; // ─── Page ───────────────────────────────────────────────────────────────────── @@ -172,7 +178,7 @@ export const TabloDetailsPage = () => { }; const filteredMembers = members?.filter( - (member) => !pendingInvites?.some((invite) => invite.invited_email === member.email), + (member) => !pendingInvites?.some((invite) => invite.invited_email === member.email) ); const sectionParam = searchParams.get("section") as TabSection | null; @@ -192,11 +198,10 @@ export const TabloDetailsPage = () => { toast.add( { title: "Projet introuvable", - description: - "Le projet demandé n'existe pas ou vous n'y avez pas accès", + description: "Le projet demandé n'existe pas ou vous n'y avez pas accès", type: "error", }, - { timeout: 5000 }, + { timeout: 5000 } ); navigate("/tablos"); } @@ -205,18 +210,14 @@ export const TabloDetailsPage = () => { // Tasks for this tablo (used in overview) const { data: allTasks = [] } = useAllTasks(); - const tabloTasks = (allTasks as KanbanTask[]).filter( - (t) => t.tablo_id === tabloId, - ); + const tabloTasks = (allTasks as KanbanTask[]).filter((t) => t.tablo_id === tabloId); // Etapes (parent tasks) for this tablo const { data: etapes = [] } = useTabloEtapes(tabloId); // Files for this tablo (used in overview) const { data: filesData } = useTabloFileNames(tabloId ?? ""); - const fileNames = (filesData?.fileNames ?? []).filter( - (f) => !f.startsWith("."), - ); + const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith(".")); if (isLoading) { return ( @@ -228,11 +229,7 @@ export const TabloDetailsPage = () => { if (!tablo) return null; - const { - label: statusLabel, - badgeClass, - progress, - } = getStatusConfig(tablo.status); + const { label: statusLabel, badgeClass, progress } = getStatusConfig(tablo.status); const isAdmin = tablo.is_admin; const TabloIcon = getTabloIcon(tablo.color); const iconColor = getTabloIconColor(tablo.color); @@ -246,22 +243,16 @@ export const TabloDetailsPage = () => {
{tablo.image ? ( - {tablo.name} + {tablo.name} ) : ( )}
-

- {tablo.name} -

+

{tablo.name}

@@ -289,9 +280,7 @@ export const TabloDetailsPage = () => {
Rôle : - - {isAdmin ? "Admin" : "Invité"} - + {isAdmin ? "Admin" : "Invité"}
Créé le : @@ -305,22 +294,14 @@ export const TabloDetailsPage = () => {
Statut : - + {statusLabel}
Progression :
-
+
{progress}%
@@ -338,15 +319,13 @@ export const TabloDetailsPage = () => { key={tab.id} type="button" disabled={tab.disabled} - onClick={() => - !tab.disabled && setSearchParams({ section: tab.id }) - } + onClick={() => !tab.disabled && setSearchParams({ section: tab.id })} className={cn( "flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2", isActive ? "text-[#804EEC] border-[#804EEC]" : "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100", - tab.disabled && "opacity-40 cursor-not-allowed", + tab.disabled && "opacity-40 cursor-not-allowed" )} > @@ -375,9 +354,8 @@ export const TabloDetailsPage = () => { Description du projet

- Ce projet regroupe les tâches, fichiers et événements - associés. Utilisez les onglets ci-dessus pour naviguer entre - les différentes sections. + Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les + onglets ci-dessus pour naviguer entre les différentes sections.

@@ -418,7 +396,7 @@ export const TabloDetailsPage = () => { "text-sm font-medium truncate", task.status === "done" ? "line-through text-gray-400" - : "text-gray-900 dark:text-gray-100", + : "text-gray-900 dark:text-gray-100" )} > {task.title} @@ -444,9 +422,7 @@ export const TabloDetailsPage = () => { {/* Files */}
-

- Fichiers -

+

Fichiers

{fileNames.length === 0 ? ( -

- Aucun fichier -

+

Aucun fichier

) : ( fileNames.slice(0, 5).map((fileName) => (
{
-

- {fileName} -

+

{fileName}

)} - {activeSection === "tasks" && ( - - )} - {activeSection === "files" && ( - - )} + {activeSection === "tasks" && } + {activeSection === "files" && } {activeSection === "discussion" && ( )} - {activeSection === "events" && ( - - )} + {activeSection === "events" && } {activeSection === "etapes" && ( )} + + {activeSection === "roadmap" && }
{/* Task Create Modal */} @@ -601,8 +556,18 @@ export const TabloDetailsPage = () => { className="flex items-center space-x-2 p-2 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50" >
- - + +
@@ -670,7 +635,7 @@ function EtapesSection({ tabloId: string; }) { const [expandedEtapes, setExpandedEtapes] = useState>( - new Set(etapes.map((e) => e.id)), + new Set(etapes.map((e) => e.id)) ); const [addingTaskToEtape, setAddingTaskToEtape] = useState(null); const [newTaskTitle, setNewTaskTitle] = useState(""); @@ -701,10 +666,22 @@ function EtapesSection({ }; const statusConfig: Record = { - todo: { label: "À faire", color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400" }, - in_progress: { label: "En cours", color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400" }, - in_review: { label: "Vérification", color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400" }, - done: { label: "Terminé", color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400" }, + todo: { + label: "À faire", + color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400", + }, + in_progress: { + label: "En cours", + color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400", + }, + in_review: { + label: "Vérification", + color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400", + }, + done: { + label: "Terminé", + color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400", + }, }; if (etapes.length === 0) { @@ -712,7 +689,9 @@ function EtapesSection({

Aucune étape

-

Les étapes permettent de structurer votre projet en grandes phases

+

+ Les étapes permettent de structurer votre projet en grandes phases +

); } @@ -771,14 +750,41 @@ function EtapesSection({ )}
- + {etape.due_date && ( +
+ + + {new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format( + new Date(etape.due_date) + )} + +
+ )} + + {status.label} {totalCount > 0 && (
-
+
{doneCount}/{totalCount} @@ -805,13 +811,39 @@ function EtapesSection({ {task.title} + {task.due_date && ( +
+ + + {new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + }).format(new Date(task.due_date))} + +
+ )} {task.status && ( - + {(statusConfig[task.status] ?? statusConfig.todo).label} )} @@ -837,7 +869,10 @@ function EtapesSection({ onChange={(e) => setNewTaskTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleAddTask(etape.id); - if (e.key === "Escape") { setAddingTaskToEtape(null); setNewTaskTitle(""); } + if (e.key === "Escape") { + setAddingTaskToEtape(null); + setNewTaskTitle(""); + } }} placeholder="Nom de la tâche..." className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400" @@ -852,7 +887,10 @@ function EtapesSection({
); } + +// ─── Roadmap Section ───────────────────────────────────────────────────────── + +function RoadmapSection({ etapes, tabloTasks }: { etapes: Etape[]; tabloTasks: KanbanTask[] }) { + const statusConfig: Record = { + todo: { + label: "À faire", + color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400", + }, + in_progress: { + label: "En cours", + color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400", + }, + in_review: { + label: "Vérification", + color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400", + }, + done: { + label: "Terminé", + color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400", + }, + }; + + // Sort etapes by due_date (earliest first), then by position + const sortedEtapes = [...etapes].sort((a, b) => { + if (a.due_date && b.due_date) return a.due_date.localeCompare(b.due_date); + if (a.due_date) return -1; + if (b.due_date) return 1; + return a.position - b.position; + }); + + // Tasks without an etape + const orphanTasks = tabloTasks.filter((t) => !t.parent_task_id); + + // Sort tasks by due_date + const sortTasks = (tasks: KanbanTask[]) => + [...tasks].sort((a, b) => { + if (a.due_date && b.due_date) return a.due_date.localeCompare(b.due_date); + if (a.due_date) return -1; + if (b.due_date) return 1; + return 0; + }); + + const isOverdue = (dateStr: string) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return new Date(dateStr) < today; + }; + + const formatDate = (dateStr: string) => + new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "numeric" }).format( + new Date(dateStr) + ); + + if (etapes.length === 0 && orphanTasks.length === 0) { + return ( +
+ +

Aucune roadmap

+

+ Ajoutez des étapes et des échéances pour visualiser la roadmap du projet +

+
+ ); + } + + return ( +
+ {sortedEtapes.map((etape, index) => { + const childTasks = sortTasks(tabloTasks.filter((t) => t.parent_task_id === etape.id)); + const doneCount = childTasks.filter((t) => t.status === "done").length; + const totalCount = childTasks.length; + const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0; + + const derivedStatus = + totalCount === 0 + ? "todo" + : doneCount === totalCount + ? "done" + : doneCount > 0 + ? "in_progress" + : "todo"; + const status = statusConfig[derivedStatus] ?? statusConfig.todo; + + return ( +
+ {/* Etape header */} +
+
+ + {index + 1} + +
+ +
+

+ {etape.title} +

+ {etape.description && ( +

+ {etape.description} +

+ )} +
+ + {etape.due_date && ( +
+ + {formatDate(etape.due_date)} +
+ )} + + + {status.label} + + + {totalCount > 0 && ( +
+
+
+
+ + {doneCount}/{totalCount} + +
+ )} +
+ + {/* Child tasks */} + {childTasks.length > 0 && ( +
+ {childTasks.map((task) => { + const taskStatus = statusConfig[task.status ?? "todo"] ?? statusConfig.todo; + const taskOverdue = + task.due_date && task.status !== "done" && isOverdue(task.due_date); + + return ( +
+ {task.status === "done" ? ( + + ) : ( +
+ )} + + {task.title} + + {task.due_date && ( +
+ + {formatDate(task.due_date)} +
+ )} + + {taskStatus.label} + +
+ ); + })} +
+ )} +
+ ); + })} + + {/* Orphan tasks (without Etape) */} + {orphanTasks.length > 0 && ( +
+
+
+ +
+

Sans Étape

+ + {orphanTasks.length} tâche{orphanTasks.length > 1 ? "s" : ""} + +
+
+ {sortTasks(orphanTasks).map((task) => { + const taskStatus = statusConfig[task.status ?? "todo"] ?? statusConfig.todo; + const taskOverdue = + task.due_date && task.status !== "done" && isOverdue(task.due_date); + + return ( +
+ {task.status === "done" ? ( + + ) : ( +
+ )} + + {task.title} + + {task.due_date && ( +
+ + {formatDate(task.due_date)} +
+ )} + + {taskStatus.label} + +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx index e69dc2d..1434b0f 100644 --- a/apps/main/src/pages/tasks.tsx +++ b/apps/main/src/pages/tasks.tsx @@ -3,8 +3,8 @@ import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types"; import { Button } from "@xtablo/ui/components/button"; import { DropdownMenu, - DropdownMenuContent, DropdownMenuCheckboxItem, + DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, @@ -12,6 +12,8 @@ import { } from "@xtablo/ui/components/dropdown-menu"; import { CalendarIcon, + ChevronDownIcon, + ChevronRightIcon, CircleCheckIcon, CircleIcon, Compass, @@ -22,10 +24,11 @@ import { Heart, KanbanIcon, Leaf, + ListChecksIcon, ListIcon, ListTodo, - MessageSquareIcon, MapIcon, + MessageSquareIcon, PaperclipIcon, PlusIcon, Settings2Icon, @@ -40,10 +43,10 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { twMerge } from "tailwind-merge"; -import { useAllTasks, useUpdateTask } from "../hooks/tasks"; -import { useTablosList } from "../hooks/tablos"; -import { useUser } from "../providers/UserStoreProvider"; import { TaskModal } from "../components/kanban/TaskModal"; +import { useTablosList } from "../hooks/tablos"; +import { useAllTasks, useUpdateTask } from "../hooks/tasks"; +import { useUser } from "../providers/UserStoreProvider"; type TaskStatus = "all" | "todo" | "in_progress" | "in_review" | "done"; @@ -68,17 +71,28 @@ const columnTitles = { function getTabloIcon(color: string | null | undefined) { switch (color) { - case "bg-blue-500": return Zap; - case "bg-green-500": return Leaf; - case "bg-purple-500": return Gem; - case "bg-red-500": return Flame; - case "bg-yellow-500": return Star; - case "bg-indigo-500": return Compass; - case "bg-pink-500": return Heart; - case "bg-teal-500": return Waves; - case "bg-orange-500": return Sun; - case "bg-cyan-500": return Sparkles; - default: return FolderIcon; + case "bg-blue-500": + return Zap; + case "bg-green-500": + return Leaf; + case "bg-purple-500": + return Gem; + case "bg-red-500": + return Flame; + case "bg-yellow-500": + return Star; + case "bg-indigo-500": + return Compass; + case "bg-pink-500": + return Heart; + case "bg-teal-500": + return Waves; + case "bg-orange-500": + return Sun; + case "bg-cyan-500": + return Sparkles; + default: + return FolderIcon; } } @@ -105,11 +119,10 @@ export function TasksPage() { const searchQuery = searchParams.get("q") ?? ""; // Get view mode from URL params, default to "kanban" - const viewMode = - (searchParams.get("view") as "kanban" | "aggregated") || "kanban"; + const viewMode = (searchParams.get("view") as "kanban" | "aggregated" | "roadmap") || "kanban"; // Function to update view mode in URL - const setViewMode = (mode: "kanban" | "aggregated") => { + const setViewMode = (mode: "kanban" | "aggregated" | "roadmap") => { const newParams = new URLSearchParams(searchParams); newParams.set("view", mode); setSearchParams(newParams); @@ -143,9 +156,7 @@ export function TasksPage() { } else if (assigneeFilter === "unassigned") { filtered = filtered.filter((task) => !task.assignee_id); } else { - filtered = filtered.filter( - (task) => task.assignee_id === assigneeFilter, - ); + filtered = filtered.filter((task) => task.assignee_id === assigneeFilter); } } @@ -154,8 +165,7 @@ export function TasksPage() { const q = searchQuery.toLowerCase(); filtered = filtered.filter( (task) => - task.title?.toLowerCase().includes(q) || - task.description?.toLowerCase().includes(q), + task.title?.toLowerCase().includes(q) || task.description?.toLowerCase().includes(q) ); } @@ -231,7 +241,7 @@ export function TasksPage() { const handleDrop = ( e: React.DragEvent, - targetStatus: "todo" | "in_progress" | "in_review" | "done", + targetStatus: "todo" | "in_progress" | "in_review" | "done" ) => { e.preventDefault(); const taskId = e.dataTransfer.getData("taskId"); @@ -252,13 +262,7 @@ export function TasksPage() { const viewTabs = [ { id: "kanban" as const, label: "Tableau", icon: KanbanIcon }, { id: "aggregated" as const, label: "Liste", icon: ListIcon }, - { - id: "gantt" as const, - label: "Roadmap", - icon: MapIcon, - disabled: true, - comingSoon: true, - }, + { id: "roadmap" as const, label: "Roadmap", icon: MapIcon }, { id: "calendar" as const, label: "Calendrier", @@ -296,15 +300,14 @@ export function TasksPage() { type="button" disabled={tab.disabled} onClick={() => - !tab.disabled && - setViewMode(tab.id as "kanban" | "aggregated") + !tab.disabled && setViewMode(tab.id as "kanban" | "aggregated" | "roadmap") } className={twMerge( "flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2", isActive ? "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400" : "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100", - tab.disabled && "cursor-not-allowed", + tab.disabled && "cursor-not-allowed" )} > @@ -323,10 +326,7 @@ export function TasksPage() {
- @@ -350,7 +350,7 @@ export function TasksPage() {
{tablo.name} @@ -406,7 +406,9 @@ export function TasksPage() { {/* Main Content */}
- {viewMode === "kanban" ? ( + {viewMode === "roadmap" ? ( + + ) : viewMode === "kanban" ? ( /* Kanban Board */ <> {tablosLoading || tasksLoading ? ( @@ -444,9 +446,7 @@ export function TasksPage() { {/* Column header */}
- +

{column.title}

@@ -476,13 +476,16 @@ export function TasksPage() { ) : ( column.tasks.map((task) => { const taskWithTablo = task as TaskWithTablo; - // const formattedDate = task.due_date - // ? new Intl.DateTimeFormat("en-US", { - // month: "short", - // day: "2-digit", - // year: "numeric", - // }).format(new Date(task.due_date)) - // : null; + const formattedDate = task.due_date + ? new Intl.DateTimeFormat("fr-FR", { + month: "short", + day: "2-digit", + }).format(new Date(task.due_date)) + : null; + const isOverdue = + task.due_date && + task.status !== "done" && + new Date(task.due_date) < new Date(new Date().toDateString()); return (
- { e.stopPropagation(); handleTaskClick(task); }}> + { + e.stopPropagation(); + handleTaskClick(task); + }} + > Ouvrir la tâche @@ -530,46 +538,50 @@ export function TasksPage() {
- {/* Due date — commented out until field is available {formattedDate && ( -
+
{formattedDate}
)} - */} {/* Tablo row */} - {taskWithTablo.tablos && (() => { - const TabloIcon = getTabloIcon(taskWithTablo.tablos.color); - const iconColor = getTabloIconColor(taskWithTablo.tablos.color); - return ( -
-
- + {taskWithTablo.tablos && + (() => { + const TabloIcon = getTabloIcon(taskWithTablo.tablos.color); + const iconColor = getTabloIconColor(taskWithTablo.tablos.color); + return ( +
+
+ +
+ + {taskWithTablo.tablos.name} +
- - {taskWithTablo.tablos.name} - -
- ); - })()} + ); + })()} {/* Footer: stats + assignee */}
- - 0 + 0
- - 0 + 0
@@ -584,9 +596,7 @@ export function TasksPage() { /> ) : (
- {task.assignee_name - ?.charAt(0) - .toUpperCase() || ( + {task.assignee_name?.charAt(0).toUpperCase() || ( )}
@@ -632,20 +642,26 @@ export function TasksPage() {
{columns.map((column) => { if (column.tasks.length === 0) return null; - const columnIconColor = { - todo: "text-gray-400", - in_progress: "text-yellow-500", - in_review: "text-blue-500", - done: "text-green-500", - }[column.status] ?? "text-gray-400"; + const columnIconColor = + { + todo: "text-gray-400", + in_progress: "text-yellow-500", + in_review: "text-blue-500", + done: "text-green-500", + }[column.status] ?? "text-gray-400"; return ( -
+
{/* Column header */}
-

{column.title}

+

+ {column.title} +

{column.tasks.length} @@ -661,18 +677,28 @@ export function TasksPage() { {/* Table */}
- +
- + + - - - + + + + @@ -701,21 +727,63 @@ export function TasksPage() { {/* Project */} + + {/* Due date */} + @@ -736,7 +804,9 @@ export function TasksPage() { title={task.assignee_name || ""} className="w-6 h-6 rounded-full bg-purple-200 dark:bg-purple-900 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold border border-white dark:border-gray-800" > - {task.assignee_name?.charAt(0).toUpperCase() || } + {task.assignee_name?.charAt(0).toUpperCase() || ( + + )} ) ) : ( @@ -760,7 +830,12 @@ export function TasksPage() { - { e.stopPropagation(); handleTaskClick(task); }}> + { + e.stopPropagation(); + handleTaskClick(task); + }} + > Ouvrir la tâche @@ -772,7 +847,10 @@ export function TasksPage() { key={s} onClick={(e) => { e.stopPropagation(); - updateTaskMutation.mutate({ id: task.id, status: s }); + updateTaskMutation.mutate({ + id: task.id, + status: s, + }); }} > {columnTitles[s]} @@ -807,3 +885,283 @@ export function TasksPage() { ); } + +// ─── Roadmap View ──────────────────────────────────────────────────────────── + +function formatRoadmapDate(dateStr: string): string { + return new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(dateStr)); +} + +function isDateOverdue(dateStr: string): boolean { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return new Date(dateStr) < today; +} + +type RoadmapTaskWithTablo = KanbanTask & { + tablos: { id: string; name: string; color: string | null } | null; +}; + +function RoadmapView({ tasks, isLoading }: { tasks: RoadmapTaskWithTablo[]; isLoading: boolean }) { + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const { t } = useTranslation(["pages"]); + + // Group tasks: tasks with parent_task_id grouped under their Etape, others under "Sans Étape" + const groups = useMemo(() => { + const groupMap = new Map< + string, + { id: string; title: string; due_date: string | null; tasks: RoadmapTaskWithTablo[] } + >(); + + // Group tasks by parent_task_id + for (const task of tasks) { + const groupId = task.parent_task_id || "__no_etape__"; + if (!groupMap.has(groupId)) { + groupMap.set(groupId, { + id: groupId, + title: groupId === "__no_etape__" ? "Sans Étape" : `Étape`, + due_date: null, + tasks: [], + }); + } + groupMap.get(groupId)!.tasks.push(task); + } + + // Sort groups: those with due dates first (earliest), then those without + const sorted = Array.from(groupMap.values()).sort((a, b) => { + // Earliest task due date in each group as a proxy for the group due date + const aDue = a.tasks.reduce((min, t) => { + if (!t.due_date) return min; + if (!min) return t.due_date; + return t.due_date < min ? t.due_date : min; + }, null); + const bDue = b.tasks.reduce((min, t) => { + if (!t.due_date) return min; + if (!min) return t.due_date; + return t.due_date < min ? t.due_date : min; + }, null); + + if (aDue && bDue) return aDue.localeCompare(bDue); + if (aDue) return -1; + if (bDue) return 1; + return 0; + }); + + return sorted; + }, [tasks]); + + // Initialize expanded groups + useMemo(() => { + if (expandedGroups.size === 0 && groups.length > 0) { + setExpandedGroups(new Set(groups.map((g) => g.id))); + } + }, [groups, expandedGroups.size]); + + const toggleGroup = (id: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (tasks.length === 0) { + return ( +
+ +

+ {t("pages:tasks.emptyState.title")} +

+

+ Ajoutez des échéances à vos tâches pour visualiser la roadmap +

+
+ ); + } + + const statusConfig: Record = { + todo: { + label: "À faire", + color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400", + }, + in_progress: { + label: "En cours", + color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400", + }, + in_review: { + label: "Vérification", + color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400", + }, + done: { + label: "Terminé", + color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400", + }, + }; + + return ( +
+ {groups.map((group) => { + const isExpanded = expandedGroups.has(group.id); + const doneCount = group.tasks.filter((t) => t.status === "done").length; + const totalCount = group.tasks.length; + const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0; + + // Sort tasks within group by due_date (earliest first), then tasks without dates + const sortedTasks = [...group.tasks].sort((a, b) => { + if (a.due_date && b.due_date) return a.due_date.localeCompare(b.due_date); + if (a.due_date) return -1; + if (b.due_date) return 1; + return 0; + }); + + // Earliest due date in this group + const earliestDue = sortedTasks.find((t) => t.due_date)?.due_date; + + return ( +
+ {/* Group header */} + + + {/* Tasks */} + {isExpanded && ( +
+ {sortedTasks.map((task) => { + const status = statusConfig[task.status ?? "todo"] ?? statusConfig.todo; + const overdue = + task.due_date && task.status !== "done" && isDateOverdue(task.due_date); + const TabloIcon = task.tablos ? getTabloIcon(task.tablos.color) : null; + const iconColor = task.tablos ? getTabloIconColor(task.tablos.color) : ""; + + return ( +
+ {task.status === "done" ? ( + + ) : ( +
+ )} + + {task.title} + + + {/* Tablo badge */} + {task.tablos && TabloIcon && ( +
+
+ +
+ + {task.tablos.name} + +
+ )} + + {/* Due date */} + {task.due_date && ( +
+ + {formatRoadmapDate(task.due_date)} +
+ )} + + {/* Status */} + + {status.label} + +
+ ); + })} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index 08c47a0..a3cd0da 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -607,6 +607,7 @@ export type Database = { assignee_id: string | null; created_at: string; description: string | null; + due_date: string | null; id: string; is_parent: boolean; parent_task_id: string | null; @@ -620,6 +621,7 @@ export type Database = { assignee_id?: string | null; created_at?: string; description?: string | null; + due_date?: string | null; id?: string; is_parent?: boolean; parent_task_id?: string | null; @@ -633,6 +635,7 @@ export type Database = { assignee_id?: string | null; created_at?: string; description?: string | null; + due_date?: string | null; id?: string; is_parent?: boolean; parent_task_id?: string | null; @@ -725,6 +728,7 @@ export type Database = { assignee_name: string | null; created_at: string | null; description: string | null; + due_date: string | null; id: string | null; is_parent: boolean | null; parent_task_id: string | null; diff --git a/supabase/migrations/20260224000000_add_due_date_to_tasks.sql b/supabase/migrations/20260224000000_add_due_date_to_tasks.sql new file mode 100644 index 0000000..b75882c --- /dev/null +++ b/supabase/migrations/20260224000000_add_due_date_to_tasks.sql @@ -0,0 +1,32 @@ +-- Add due_date column to tasks table for roadmap feature +ALTER TABLE "public"."tasks" + ADD COLUMN "due_date" date; + +COMMENT ON COLUMN "public"."tasks"."due_date" IS 'Optional due date for tasks and Etapes, used in roadmap views'; + +-- Index for roadmap queries (filtering/sorting by due_date within a tablo) +CREATE INDEX "tasks_tablo_due_date_idx" ON "public"."tasks" USING btree ("tablo_id", "due_date"); + +-- Update tasks_with_assignee view to include due_date +CREATE OR REPLACE VIEW "public"."tasks_with_assignee" WITH ("security_invoker"='true') AS +SELECT + t.id, + t.tablo_id, + t.title, + t.description, + t.status, + t.assignee_id, + t.position, + t.created_at, + t.updated_at, + p.name AS assignee_name, + p.avatar_url AS assignee_avatar, + t.is_parent, + t.parent_task_id, + t.due_date +FROM "public"."tasks" t +LEFT JOIN "public"."profiles" p ON t.assignee_id = p.id; + +ALTER TABLE "public"."tasks_with_assignee" OWNER TO "postgres"; + +COMMENT ON VIEW "public"."tasks_with_assignee" IS 'View that returns tasks with assignee information from profiles';
TÂCHEPROJETPERSONNES + TÂCHE + + PROJET + + ÉCHÉANCE + + PERSONNES +
- {taskWithTablo.tablos ? (() => { + {taskWithTablo.tablos ? ( + (() => { const TabloIcon = getTabloIcon(taskWithTablo.tablos.color); - const iconColor = getTabloIconColor(taskWithTablo.tablos.color); + const iconColor = getTabloIconColor( + taskWithTablo.tablos.color + ); return (
-
- +
+
- {taskWithTablo.tablos.name} + + {taskWithTablo.tablos.name} +
); - })() : ( + })() + ) : ( + + )} +
+ {task.due_date ? ( + (() => { + const dueDateOverdue = + task.status !== "done" && + new Date(task.due_date) < + new Date(new Date().toDateString()); + return ( +
+ + + {new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + }).format(new Date(task.due_date))} + +
+ ); + })() + ) : ( )}