Redesign Roadmap view as Gantt chart with weekly timeline
- Create shared GanttChart component with week navigation, day columns, today indicator, and positioned task cards - Task cards color-coded by status (blue/orange/purple/green) - Replace list-based RoadmapView in Tasks page with GanttChart - Replace list-based RoadmapSection in Tablo Details page with GanttChart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b09dd4093
commit
c5ed1f0bf0
3 changed files with 425 additions and 538 deletions
420
apps/main/src/components/gantt/GanttChart.tsx
Normal file
420
apps/main/src/components/gantt/GanttChart.tsx
Normal file
|
|
@ -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<string, { bg: string; border: string; dot: string; label: string }> = {
|
||||
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<string, string> = {
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Navigation bar */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-lg"
|
||||
onClick={() => setWeekOffset((w) => w - 1)}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="px-4 py-2 bg-card border border-border rounded-lg min-w-[200px] text-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatDateRange(weekStart, weekEnd)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-lg"
|
||||
onClick={() => setWeekOffset((w) => w + 1)}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2 rounded-lg">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>Semaine</span>
|
||||
<ChevronRightIcon className="h-4 w-4 rotate-90" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setWeekOffset(0)}>
|
||||
Aller à aujourd'hui
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Gantt chart */}
|
||||
<div
|
||||
className="bg-card rounded-xl border border-border overflow-hidden shadow-sm"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<div style={{ minWidth: 992 }}>
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-border">
|
||||
{days.map((day, i) => {
|
||||
const isToday = isSameDay(day, today);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col items-center py-3 relative"
|
||||
style={{ width: colWidth }}
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
isToday ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{formatShortDay(day)} {day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid body */}
|
||||
<div className="relative" style={{ height: chartHeight }}>
|
||||
{/* Vertical grid lines */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{days.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={twMerge("border-border", i < 6 ? "border-r" : "")}
|
||||
style={{ width: colWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Today indicator */}
|
||||
{todayInRange && (
|
||||
<div
|
||||
className="absolute z-10 pointer-events-none"
|
||||
style={{ left: (todayIndex + 0.5) * colWidth, top: 0, height: chartHeight }}
|
||||
>
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2">
|
||||
<div className="w-0 h-0 border-l-[8px] border-r-[8px] border-t-[10px] border-l-transparent border-r-transparent border-t-primary" />
|
||||
</div>
|
||||
<div className="w-0.5 h-full bg-primary mx-auto opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={pt.task.id}
|
||||
className={twMerge(
|
||||
"absolute rounded-lg p-3 border-l-4 shadow-sm transition-all hover:shadow-md cursor-pointer",
|
||||
status.bg,
|
||||
status.border
|
||||
)}
|
||||
style={{
|
||||
left: pt.left,
|
||||
width: pt.width,
|
||||
top: pt.top,
|
||||
minWidth: 160,
|
||||
}}
|
||||
>
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-1.5 bg-white w-fit px-2.5 py-1 rounded-full shadow-sm">
|
||||
<span className={twMerge("w-2 h-2 rounded-full", status.dot)} />
|
||||
<span className={twMerge("text-xs font-medium", textColor)}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-foreground mt-2 text-sm leading-tight line-clamp-1">
|
||||
{pt.task.title}
|
||||
</h3>
|
||||
|
||||
{/* Due date */}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(pt.task.due_date!).toLocaleDateString("fr-FR", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Tablo badge */}
|
||||
{pt.task.tablos && TabloIcon && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-md flex items-center justify-center",
|
||||
pt.task.tablos.color || "bg-gray-400"
|
||||
)}
|
||||
>
|
||||
<TabloIcon className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{pt.task.tablos.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state within chart */}
|
||||
{positionedTasks.length === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<MapIcon className="w-10 h-10 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucune tâche avec échéance cette semaine
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, { label: string; color: string }> = {
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<MapIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucune roadmap</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
Ajoutez des étapes et des échéances pour visualiser la roadmap du projet
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div
|
||||
key={etape.id}
|
||||
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Etape header */}
|
||||
<div className="flex items-center gap-4 px-5 py-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{etape.title}
|
||||
</h3>
|
||||
{etape.description && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
{etape.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{etape.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
derivedStatus !== "done" && isOverdue(etape.due_date)
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>{formatDate(etape.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-full text-xs font-medium shrink-0",
|
||||
status.color
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Child tasks */}
|
||||
{childTasks.length > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{childTasks.map((task) => {
|
||||
const taskStatus = statusConfig[task.status ?? "todo"] ?? statusConfig.todo;
|
||||
const taskOverdue =
|
||||
task.due_date && task.status !== "done" && isOverdue(task.due_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-5 py-3 pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
taskOverdue ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>{formatDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
taskStatus.color
|
||||
)}
|
||||
>
|
||||
{taskStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan tasks (without Etape) */}
|
||||
{orphanTasks.length > 0 && (
|
||||
<div className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-4 px-5 py-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center shrink-0">
|
||||
<ListChecksIcon className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 flex-1">Sans Étape</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{orphanTasks.length} tâche{orphanTasks.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{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 (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-5 py-3 pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
taskOverdue ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>{formatDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
taskStatus.color
|
||||
)}
|
||||
>
|
||||
{taskStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
function RoadmapSection({ tabloTasks }: { etapes: Etape[]; tabloTasks: KanbanTask[] }) {
|
||||
return <GanttChart tasks={tabloTasks} isLoading={false} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(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<string | null>((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<string | null>((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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="p-12 text-center">
|
||||
<MapIcon className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">
|
||||
{t("pages:tasks.emptyState.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ajoutez des échéances à vos tâches pour visualiser la roadmap
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Group header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
|
||||
<ListChecksIcon className="w-4 h-4 text-[#7F56D9] dark:text-purple-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{earliestDue && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
isDateOverdue(earliestDue) ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>{formatRoadmapDate(earliestDue)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tasks */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{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 (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-5 py-3 pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
|
||||
{/* Tablo badge */}
|
||||
{task.tablos && TabloIcon && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-4 h-4 rounded-[3px] flex items-center justify-center",
|
||||
task.tablos.color || "bg-gray-400"
|
||||
)}
|
||||
>
|
||||
<TabloIcon className={twMerge("w-2.5 h-2.5", iconColor)} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 max-w-[100px] truncate">
|
||||
{task.tablos.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Due date */}
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
overdue ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>{formatRoadmapDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<span
|
||||
className={twMerge(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
status.color
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return <GanttChart tasks={tasks} isLoading={isLoading} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue