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 */}
- {/*
- Type
- setType(value as TaskType)}>
-
-
-
-
- Task
- Story
- Bug
- Epic
- Subtask
-
-
-
*/}
+ {/* Due Date */}
+
+ Échéance
+
+
{/* 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}
@@ -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
setSearchParams({ section: "files" })}
@@ -457,9 +433,7 @@ export const TabloDetailsPage = () => {
{fileNames.length === 0 ? (
-
- Aucun fichier
-
+
Aucun fichier
) : (
fileNames.slice(0, 5).map((fileName) => (
{
-
- {fileName}
-
+
{fileName}
{
{/* Info */}
-
- Informations
-
+
Informations
Tâches
-
- {tabloTasks.length}
-
+ {tabloTasks.length}
Fichiers
-
- {fileNames.length}
-
+ {fileNames.length}
Statut
-
+
{statusLabel}
Rôle
-
- {isAdmin ? "Admin" : "Invité"}
-
+ {isAdmin ? "Admin" : "Invité"}
@@ -527,22 +486,18 @@ export const TabloDetailsPage = () => {
)}
- {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({
{ setAddingTaskToEtape(null); setNewTaskTitle(""); }}
+ onClick={() => {
+ setAddingTaskToEtape(null);
+ setNewTaskTitle("");
+ }}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
>
Annuler
@@ -861,7 +899,11 @@ function EtapesSection({
) : (
{ e.stopPropagation(); setAddingTaskToEtape(etape.id); setNewTaskTitle(""); }}
+ onClick={(e) => {
+ e.stopPropagation();
+ setAddingTaskToEtape(etape.id);
+ setNewTaskTitle("");
+ }}
className="flex items-center gap-2 px-5 py-2.5 pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700"
>
@@ -876,3 +918,271 @@ 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() {
-
+
Filtrer
@@ -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 */}
@@ -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 */}
-
+
-
+
+
- TÂCHE
- PROJET
- PERSONNES
+
+ TÂCHE
+
+
+ PROJET
+
+
+ ÉCHÉANCE
+
+
+ PERSONNES
+
@@ -701,21 +727,63 @@ export function TasksPage() {
{/* Project */}
- {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}
+
);
- })() : (
+ })()
+ ) : (
+
—
+ )}
+
+
+ {/* Due date */}
+
+ {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))}
+
+
+ );
+ })()
+ ) : (
—
)}
@@ -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 */}
+
toggleGroup(group.id)}
+ className="w-full flex items-center gap-4 px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {group.title}
+
+
+
+ {earliestDue && (
+
+
+ {formatRoadmapDate(earliestDue)}
+
+ )}
+
+ {totalCount > 0 && (
+
+
+
+ {doneCount}/{totalCount}
+
+
+ )}
+
+
+ {/* 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';