Add biweekly view mode to Gantt chart roadmap
- Add view mode dropdown with "Semaine" (7 days) and "2 semaines" (14 days) - Biweekly mode shows compact cards (smaller padding, no tablo badge, shorter labels) - Navigation steps by 1 or 2 weeks based on current view mode - Dynamic column count and card sizing based on view config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5ed1f0bf0
commit
349ba4ae7c
1 changed files with 65 additions and 33 deletions
|
|
@ -135,7 +135,13 @@ function formatDateRange(start: Date, end: Date): string {
|
|||
return `${startStr} - ${endStr}`;
|
||||
}
|
||||
|
||||
const CARD_HEIGHT = 130;
|
||||
type ViewMode = "weekly" | "biweekly";
|
||||
|
||||
const VIEW_CONFIG = {
|
||||
weekly: { days: 7, label: "Semaine", cardHeight: 130, stepWeeks: 1 },
|
||||
biweekly: { days: 14, label: "2 semaines", cardHeight: 100, stepWeeks: 2 },
|
||||
} as const;
|
||||
|
||||
const CARD_GAP = 10;
|
||||
const CARD_TOP_OFFSET = 20;
|
||||
|
||||
|
|
@ -143,9 +149,14 @@ const CARD_TOP_OFFSET = 20;
|
|||
|
||||
export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
||||
const [weekOffset, setWeekOffset] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("weekly");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(992);
|
||||
|
||||
const config = VIEW_CONFIG[viewMode];
|
||||
const numDays = config.days;
|
||||
const cardHeight = config.cardHeight;
|
||||
|
||||
// Measure container width
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
|
|
@ -159,37 +170,37 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colWidth = containerWidth / 7;
|
||||
const colWidth = containerWidth / numDays;
|
||||
|
||||
// Compute week days
|
||||
// Compute 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 periodStart = useMemo(() => addDays(getMonday(today), weekOffset * 7), [today, weekOffset]);
|
||||
const periodEnd = useMemo(() => addDays(periodStart, numDays - 1), [periodStart, numDays]);
|
||||
|
||||
const days = useMemo(
|
||||
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
|
||||
[weekStart]
|
||||
() => Array.from({ length: numDays }, (_, i) => addDays(periodStart, i)),
|
||||
[periodStart, numDays]
|
||||
);
|
||||
|
||||
// Filter tasks with due_date in this week
|
||||
// Filter tasks with due_date in this period
|
||||
const visibleTasks = useMemo(() => {
|
||||
const start = weekStart.getTime();
|
||||
const end = addDays(weekStart, 7).getTime();
|
||||
const start = periodStart.getTime();
|
||||
const end = addDays(periodStart, numDays).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]);
|
||||
}, [tasks, periodStart, numDays]);
|
||||
|
||||
// Position tasks: group by day column, stack vertically
|
||||
const positionedTasks = useMemo(() => {
|
||||
const columnSlots: number[][] = Array.from({ length: 7 }, () => []);
|
||||
const columnSlots: number[][] = Array.from({ length: numDays }, () => []);
|
||||
|
||||
return visibleTasks
|
||||
.map((task) => {
|
||||
|
|
@ -207,9 +218,9 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
task,
|
||||
dayIndex,
|
||||
row,
|
||||
left: dayIndex * colWidth + 8,
|
||||
top: CARD_TOP_OFFSET + row * (CARD_HEIGHT + CARD_GAP),
|
||||
width: colWidth - 16,
|
||||
left: dayIndex * colWidth + 4,
|
||||
top: CARD_TOP_OFFSET + row * (cardHeight + CARD_GAP),
|
||||
width: colWidth - 8,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
|
|
@ -220,16 +231,21 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
top: number;
|
||||
width: number;
|
||||
}>;
|
||||
}, [visibleTasks, days, colWidth]);
|
||||
}, [visibleTasks, days, colWidth, numDays, cardHeight]);
|
||||
|
||||
// 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);
|
||||
const chartHeight = Math.max(400, (maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20);
|
||||
|
||||
// Today indicator position
|
||||
const todayIndex = days.findIndex((d) => isSameDay(d, today));
|
||||
const todayInRange = todayIndex >= 0;
|
||||
|
||||
const handleViewModeChange = (mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
setWeekOffset(0);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -247,20 +263,20 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-lg"
|
||||
onClick={() => setWeekOffset((w) => w - 1)}
|
||||
onClick={() => setWeekOffset((w) => w - config.stepWeeks)}
|
||||
>
|
||||
<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)}
|
||||
{formatDateRange(periodStart, periodEnd)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-lg"
|
||||
onClick={() => setWeekOffset((w) => w + 1)}
|
||||
onClick={() => setWeekOffset((w) => w + config.stepWeeks)}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -270,11 +286,17 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2 rounded-lg">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>Semaine</span>
|
||||
<span>{config.label}</span>
|
||||
<ChevronRightIcon className="h-4 w-4 rotate-90" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewModeChange("weekly")}>
|
||||
Semaine
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleViewModeChange("biweekly")}>
|
||||
2 semaines
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setWeekOffset(0)}>
|
||||
Aller à aujourd'hui
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -319,7 +341,7 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
{days.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={twMerge("border-border", i < 6 ? "border-r" : "")}
|
||||
className={twMerge("border-border", i < numDays - 1 ? "border-r" : "")}
|
||||
style={{ width: colWidth }}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -345,11 +367,14 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ?? STATUS_TEXT_COLORS.todo;
|
||||
const TabloIcon = pt.task.tablos ? getTabloIcon(pt.task.tablos.color) : null;
|
||||
|
||||
const isCompact = viewMode === "biweekly";
|
||||
|
||||
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",
|
||||
"absolute rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md cursor-pointer overflow-hidden",
|
||||
isCompact ? "p-2" : "p-3",
|
||||
status.bg,
|
||||
status.border
|
||||
)}
|
||||
|
|
@ -357,19 +382,26 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
left: pt.left,
|
||||
width: pt.width,
|
||||
top: pt.top,
|
||||
minWidth: 160,
|
||||
minWidth: isCompact ? 80 : 160,
|
||||
}}
|
||||
>
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-1.5 bg-white w-fit px-2.5 py-1 rounded-full shadow-sm">
|
||||
<div className="flex items-center gap-1.5 bg-white w-fit px-2 py-0.5 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>
|
||||
{!isCompact && (
|
||||
<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">
|
||||
<h3
|
||||
className={twMerge(
|
||||
"font-semibold text-foreground leading-tight line-clamp-1",
|
||||
isCompact ? "mt-1 text-xs" : "mt-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{pt.task.title}
|
||||
</h3>
|
||||
|
||||
|
|
@ -378,12 +410,12 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
{new Date(pt.task.due_date!).toLocaleDateString("fr-FR", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
month: isCompact ? undefined : "short",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Tablo badge */}
|
||||
{pt.task.tablos && TabloIcon && (
|
||||
{/* Tablo badge — hidden in compact mode */}
|
||||
{!isCompact && pt.task.tablos && TabloIcon && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
|
|
@ -407,7 +439,7 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
|
|||
<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
|
||||
Aucune tâche avec échéance sur cette période
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue