diff --git a/apps/main/src/components/gantt/GanttChart.tsx b/apps/main/src/components/gantt/GanttChart.tsx new file mode 100644 index 0000000..f778e4a --- /dev/null +++ b/apps/main/src/components/gantt/GanttChart.tsx @@ -0,0 +1,420 @@ +import { LoadingSpinner } from "@ui/components/LoadingSpinner"; +import type { KanbanTask } from "@xtablo/shared-types"; +import { Button } from "@xtablo/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@xtablo/ui/components/dropdown-menu"; +import { + CalendarIcon, + ChevronLeftIcon, + ChevronRightIcon, + Compass, + Flame, + FolderIcon, + Gem, + Heart, + Leaf, + MapIcon, + Sparkles, + Star, + Sun, + Waves, + Zap, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { twMerge } from "tailwind-merge"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type GanttTask = KanbanTask & { + tablos?: { id: string; name: string; color: string | null } | null; +}; + +interface GanttChartProps { + tasks: GanttTask[]; + isLoading: boolean; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const STATUS_STYLES: Record = { + todo: { + bg: "bg-[#EFF8FF]", + border: "border-l-[#3B82F6]", + dot: "bg-[#1570EF]", + label: "À faire", + }, + in_progress: { + bg: "bg-[#FFF4E2]", + border: "border-l-[#F1A62D]", + dot: "bg-[#DB9729]", + label: "En cours", + }, + in_review: { + bg: "bg-[#F4F3FF]", + border: "border-l-[#804EEC]", + dot: "bg-[#804EEC]", + label: "Vérification", + }, + done: { + bg: "bg-[#EDFCF2]", + border: "border-l-[#16B364]", + dot: "bg-[#16B364]", + label: "Terminé", + }, +}; + +const STATUS_TEXT_COLORS: Record = { + todo: "text-[#1570EF]", + in_progress: "text-[#DB9729]", + in_review: "text-[#804EEC]", + done: "text-[#16B364]", +}; + +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; + } +} + +function getMonday(date: Date): Date { + const d = new Date(date); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + d.setDate(diff); + d.setHours(0, 0, 0, 0); + return d; +} + +function addDays(date: Date, n: number): Date { + const d = new Date(date); + d.setDate(d.getDate() + n); + return d; +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function formatShortDay(date: Date): string { + return date.toLocaleDateString("fr-FR", { weekday: "short" }).replace(".", ""); +} + +function formatDateRange(start: Date, end: Date): string { + const opts: Intl.DateTimeFormatOptions = { month: "long", day: "numeric" }; + const startStr = start.toLocaleDateString("fr-FR", opts); + const endStr = end.toLocaleDateString("fr-FR", opts); + return `${startStr} - ${endStr}`; +} + +const CARD_HEIGHT = 130; +const CARD_GAP = 10; +const CARD_TOP_OFFSET = 20; + +// ─── Component ─────────────────────────────────────────────────────────────── + +export function GanttChart({ tasks, isLoading }: GanttChartProps) { + const [weekOffset, setWeekOffset] = useState(0); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(992); + + // Measure container width + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(Math.max(entry.contentRect.width, 992)); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const colWidth = containerWidth / 7; + + // Compute week days + const today = useMemo(() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; + }, []); + + const weekStart = useMemo(() => addDays(getMonday(today), weekOffset * 7), [today, weekOffset]); + const weekEnd = useMemo(() => addDays(weekStart, 6), [weekStart]); + + const days = useMemo( + () => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)), + [weekStart] + ); + + // Filter tasks with due_date in this week + const visibleTasks = useMemo(() => { + const start = weekStart.getTime(); + const end = addDays(weekStart, 7).getTime(); + return tasks.filter((t) => { + if (!t.due_date) return false; + const d = new Date(t.due_date).getTime(); + return d >= start && d < end; + }); + }, [tasks, weekStart]); + + // Position tasks: group by day column, stack vertically + const positionedTasks = useMemo(() => { + const columnSlots: number[][] = Array.from({ length: 7 }, () => []); + + return visibleTasks + .map((task) => { + const dueDate = new Date(task.due_date!); + const dayIndex = days.findIndex((d) => isSameDay(d, dueDate)); + if (dayIndex === -1) return null; + + // Find first available row in this column + const slots = columnSlots[dayIndex]; + let row = 0; + while (slots.includes(row)) row++; + slots.push(row); + + return { + task, + dayIndex, + row, + left: dayIndex * colWidth + 8, + top: CARD_TOP_OFFSET + row * (CARD_HEIGHT + CARD_GAP), + width: colWidth - 16, + }; + }) + .filter(Boolean) as Array<{ + task: GanttTask; + dayIndex: number; + row: number; + left: number; + top: number; + width: number; + }>; + }, [visibleTasks, days, colWidth]); + + // Compute chart height + const maxRow = positionedTasks.reduce((max, pt) => Math.max(max, pt.row), 0); + const chartHeight = Math.max(400, (maxRow + 1) * (CARD_HEIGHT + CARD_GAP) + CARD_TOP_OFFSET + 20); + + // Today indicator position + const todayIndex = days.findIndex((d) => isSameDay(d, today)); + const todayInRange = todayIndex >= 0; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Navigation bar */} +
+
+ +
+ + {formatDateRange(weekStart, weekEnd)} + +
+ +
+ + + + + + + setWeekOffset(0)}> + Aller à aujourd'hui + + + +
+ + {/* Gantt chart */} +
+
+
+ {/* Day headers */} +
+ {days.map((day, i) => { + const isToday = isSameDay(day, today); + return ( +
+ + {formatShortDay(day)} {day.getDate()} + +
+ ); + })} +
+ + {/* Grid body */} +
+ {/* Vertical grid lines */} +
+ {days.map((_, i) => ( +
+ ))} +
+ + {/* Today indicator */} + {todayInRange && ( +
+
+
+
+
+
+ )} + + {/* Task cards */} + {positionedTasks.map((pt) => { + const status = STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo; + const textColor = + STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ?? STATUS_TEXT_COLORS.todo; + const TabloIcon = pt.task.tablos ? getTabloIcon(pt.task.tablos.color) : null; + + return ( +
+ {/* Status badge */} +
+ + + {status.label} + +
+ + {/* Title */} +

+ {pt.task.title} +

+ + {/* Due date */} +

+ {new Date(pt.task.due_date!).toLocaleDateString("fr-FR", { + weekday: "short", + day: "numeric", + month: "short", + })} +

+ + {/* Tablo badge */} + {pt.task.tablos && TabloIcon && ( +
+
+ +
+ + {pt.task.tablos.name} + +
+ )} +
+ ); + })} + + {/* Empty state within chart */} + {positionedTasks.length === 0 && ( +
+ +

+ Aucune tâche avec échéance cette semaine +

+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 1694bf0..4f3997f 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -40,6 +40,7 @@ import { } from "lucide-react"; import { useEffect, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { GanttChart } from "../components/gantt/GanttChart"; import { TaskModal } from "../components/kanban/TaskModal"; import { TabloDiscussionSection } from "../components/TabloDiscussionSection"; import { TabloEventsSection } from "../components/TabloEventsSection"; @@ -921,268 +922,6 @@ 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} - -
- ); - })} -
-
- )} -
- ); +function RoadmapSection({ tabloTasks }: { etapes: Etape[]; tabloTasks: KanbanTask[] }) { + return ; } diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx index 1434b0f..26bbb28 100644 --- a/apps/main/src/pages/tasks.tsx +++ b/apps/main/src/pages/tasks.tsx @@ -12,8 +12,6 @@ import { } from "@xtablo/ui/components/dropdown-menu"; import { CalendarIcon, - ChevronDownIcon, - ChevronRightIcon, CircleCheckIcon, CircleIcon, Compass, @@ -24,7 +22,6 @@ import { Heart, KanbanIcon, Leaf, - ListChecksIcon, ListIcon, ListTodo, MapIcon, @@ -43,6 +40,7 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { twMerge } from "tailwind-merge"; +import { GanttChart } from "../components/gantt/GanttChart"; import { TaskModal } from "../components/kanban/TaskModal"; import { useTablosList } from "../hooks/tablos"; import { useAllTasks, useUpdateTask } from "../hooks/tasks"; @@ -888,280 +886,10 @@ 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} - -
- ); - })} -
- )} -
- ); - })} -
- ); + return ; }