feat(tasks): roadmap click-to-create with due date and quick status update

This commit is contained in:
Arthur Belleville 2026-02-24 10:55:16 +01:00
parent 349ba4ae7c
commit 421676c3f0
No known key found for this signature in database
4 changed files with 550 additions and 160 deletions

View file

@ -1,5 +1,5 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import type { KanbanTask } from "@xtablo/shared-types";
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
@ -36,11 +36,16 @@ type GanttTask = KanbanTask & {
interface GanttChartProps {
tasks: GanttTask[];
isLoading: boolean;
onDateClick?: (date: Date) => void;
onTaskStatusChange?: (taskId: string, status: TaskStatus) => void;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
const STATUS_STYLES: Record<string, { bg: string; border: string; dot: string; label: string }> = {
const STATUS_STYLES: Record<
string,
{ bg: string; border: string; dot: string; label: string }
> = {
todo: {
bg: "bg-[#EFF8FF]",
border: "border-l-[#3B82F6]",
@ -74,6 +79,13 @@ const STATUS_TEXT_COLORS: Record<string, string> = {
done: "text-[#16B364]",
};
const ROADMAP_TASK_STATUSES: TaskStatus[] = [
"todo",
"in_progress",
"in_review",
"done",
];
function getTabloIcon(color: string | null | undefined) {
switch (color) {
case "bg-blue-500":
@ -125,7 +137,9 @@ function isSameDay(a: Date, b: Date): boolean {
}
function formatShortDay(date: Date): string {
return date.toLocaleDateString("fr-FR", { weekday: "short" }).replace(".", "");
return date
.toLocaleDateString("fr-FR", { weekday: "short" })
.replace(".", "");
}
function formatDateRange(start: Date, end: Date): string {
@ -147,7 +161,12 @@ const CARD_TOP_OFFSET = 20;
// ─── Component ───────────────────────────────────────────────────────────────
export function GanttChart({ tasks, isLoading }: GanttChartProps) {
export function GanttChart({
tasks,
isLoading,
onDateClick,
onTaskStatusChange,
}: GanttChartProps) {
const [weekOffset, setWeekOffset] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>("weekly");
const containerRef = useRef<HTMLDivElement>(null);
@ -179,12 +198,18 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
return d;
}, []);
const periodStart = useMemo(() => addDays(getMonday(today), weekOffset * 7), [today, weekOffset]);
const periodEnd = useMemo(() => addDays(periodStart, numDays - 1), [periodStart, numDays]);
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: numDays }, (_, i) => addDays(periodStart, i)),
[periodStart, numDays]
[periodStart, numDays],
);
// Filter tasks with due_date in this period
@ -235,7 +260,10 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
// Compute chart height
const maxRow = positionedTasks.reduce((max, pt) => Math.max(max, pt.row), 0);
const chartHeight = Math.max(400, (maxRow + 1) * (cardHeight + 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));
@ -316,32 +344,50 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
{days.map((day, i) => {
const isToday = isSameDay(day, today);
return (
<div
<button
key={i}
className="flex flex-col items-center py-3 relative"
type="button"
onClick={() => onDateClick?.(new Date(day))}
className="flex flex-col items-center py-3 relative transition-colors hover:bg-accent/40"
style={{ width: colWidth }}
>
<span
className={twMerge(
"text-sm font-medium",
isToday ? "text-primary" : "text-muted-foreground"
isToday ? "text-primary" : "text-muted-foreground",
)}
>
{formatShortDay(day)} {day.getDate()}
</span>
</div>
</button>
);
})}
</div>
{/* Grid body */}
<div className="relative" style={{ height: chartHeight }}>
{/* Clickable day columns */}
<div className="absolute inset-0 flex z-0">
{days.map((day, i) => (
<button
key={`click-${i}`}
type="button"
onClick={() => onDateClick?.(new Date(day))}
className="h-full transition-colors hover:bg-accent/20"
style={{ width: colWidth }}
/>
))}
</div>
{/* Vertical grid lines */}
<div className="absolute inset-0 flex">
<div className="absolute inset-0 flex pointer-events-none z-10">
{days.map((_, i) => (
<div
key={i}
className={twMerge("border-border", i < numDays - 1 ? "border-r" : "")}
className={twMerge(
"border-border",
i < numDays - 1 ? "border-r" : "",
)}
style={{ width: colWidth }}
/>
))}
@ -350,8 +396,12 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
{/* Today indicator */}
{todayInRange && (
<div
className="absolute z-10 pointer-events-none"
style={{ left: (todayIndex + 0.5) * colWidth, top: 0, height: chartHeight }}
className="absolute z-20 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" />
@ -362,34 +412,27 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
{/* Task cards */}
{positionedTasks.map((pt) => {
const status = STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo;
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;
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 border-l-4 shadow-sm transition-all hover:shadow-md cursor-pointer overflow-hidden",
isCompact ? "p-2" : "p-3",
status.bg,
status.border
)}
style={{
left: pt.left,
width: pt.width,
top: pt.top,
minWidth: isCompact ? 80 : 160,
}}
>
const taskCardContent = (
<>
{/* Status badge */}
<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("w-2 h-2 rounded-full", status.dot)}
/>
{!isCompact && (
<span className={twMerge("text-xs font-medium", textColor)}>
<span
className={twMerge("text-xs font-medium", textColor)}
>
{status.label}
</span>
)}
@ -399,7 +442,7 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
<h3
className={twMerge(
"font-semibold text-foreground leading-tight line-clamp-1",
isCompact ? "mt-1 text-xs" : "mt-2 text-sm"
isCompact ? "mt-1 text-xs" : "mt-2 text-sm",
)}
>
{pt.task.title}
@ -420,7 +463,7 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
<div
className={twMerge(
"w-5 h-5 rounded-md flex items-center justify-center",
pt.task.tablos.color || "bg-gray-400"
pt.task.tablos.color || "bg-gray-400",
)}
>
<TabloIcon className="w-3 h-3 text-white" />
@ -430,13 +473,87 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
</span>
</div>
)}
</div>
</>
);
if (!onTaskStatusChange) {
return (
<div
key={pt.task.id}
className={twMerge(
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left cursor-default",
isCompact ? "p-2" : "p-3",
status.bg,
status.border,
)}
style={{
left: pt.left,
width: pt.width,
top: pt.top,
minWidth: isCompact ? 80 : 160,
}}
>
{taskCardContent}
</div>
);
}
return (
<DropdownMenu key={pt.task.id}>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={twMerge(
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left",
isCompact ? "p-2" : "p-3",
status.bg,
status.border,
"cursor-pointer",
)}
style={{
left: pt.left,
width: pt.width,
top: pt.top,
minWidth: isCompact ? 80 : 160,
}}
>
{taskCardContent}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{ROADMAP_TASK_STATUSES.map((nextStatus) => {
const isCurrent = nextStatus === pt.task.status;
return (
<DropdownMenuItem
key={nextStatus}
disabled={isCurrent}
onClick={() =>
onTaskStatusChange(pt.task.id, nextStatus)
}
className="gap-2"
>
<span
className={twMerge(
"w-2 h-2 rounded-full",
STATUS_STYLES[nextStatus]?.dot ?? "bg-gray-400",
)}
/>
<span>
{STATUS_STYLES[nextStatus]?.label ?? nextStatus}
{isCurrent ? " (actuel)" : ""}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
})}
{/* Empty state within chart */}
{positionedTasks.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none z-10">
<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 sur cette période

View file

@ -16,7 +16,12 @@ import { TypographyH2 } from "@xtablo/ui/components/typography";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTabloMembers } from "../../hooks/tablos";
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
import {
useCreateTask,
useTabloEtapes,
useTask,
useUpdateTask,
} from "../../hooks/tasks";
import type { TabloMember } from "./types";
interface TaskModalProps {
@ -29,6 +34,7 @@ interface TaskModalProps {
etapes?: Etape[]; // Optional - will be fetched if tabloId is provided
tablos?: UserTablo[]; // Optional - for tablo selection when creating
allowTabloSelection?: boolean; // Whether to show tablo selector
initialDueDate?: Date;
}
export const TaskModal = ({
@ -41,6 +47,7 @@ export const TaskModal = ({
etapes: providedEtapes,
tablos,
allowTabloSelection = false,
initialDueDate,
}: TaskModalProps) => {
const { data: task = null } = useTask(taskId);
const [title, setTitle] = useState("");
@ -49,20 +56,26 @@ export const TaskModal = ({
const [etapeId, setEtapeId] = useState<string>("none");
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [selectedTabloId, setSelectedTabloId] = useState<string>(
initialTabloId || tablos?.[0]?.id || ""
initialTabloId || tablos?.[0]?.id || "",
);
// Determine which tablo to use for fetching data
const tabloIdForFetch = allowTabloSelection ? selectedTabloId : initialTabloId || "";
const tabloIdForFetch = allowTabloSelection
? selectedTabloId
: initialTabloId || "";
// Fetch members and etapes for selected tablo if not provided
const { data: fetchedMembers = [] } = useTabloMembers(tabloIdForFetch || "");
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
const { data: fetchedEtapes = [] } = useTabloEtapes(
tabloIdForFetch || undefined,
);
// Use provided or fetched data
const members = providedMembers || fetchedMembers;
const etapes = providedEtapes || fetchedEtapes;
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
const currentTabloId = allowTabloSelection
? selectedTabloId
: initialTabloId || "";
useEffect(() => {
if (task) {
@ -80,12 +93,12 @@ export const TaskModal = ({
setDescription("");
setAssigneeId("unassigned");
setEtapeId("none");
setDueDate(undefined);
setDueDate(initialDueDate ? new Date(initialDueDate) : undefined);
if (allowTabloSelection && tablos && tablos.length > 0) {
setSelectedTabloId(tablos[0].id);
}
}
}, [task, initialTabloId, allowTabloSelection, tablos]);
}, [task, initialTabloId, allowTabloSelection, tablos, initialDueDate]);
const { mutate: createTask } = useCreateTask();
const { mutate: updateTask } = useUpdateTask();
@ -161,7 +174,10 @@ export const TaskModal = ({
{allowTabloSelection && !taskId && tablos && tablos.length > 0 && (
<div className="space-y-2">
<Label htmlFor="tablo">Tablo *</Label>
<Select value={selectedTabloId} onValueChange={setSelectedTabloId}>
<Select
value={selectedTabloId}
onValueChange={setSelectedTabloId}
>
<SelectTrigger id="tablo" className="w-full">
<SelectValue placeholder="Sélectionner un tablo" />
</SelectTrigger>
@ -209,7 +225,11 @@ export const TaskModal = ({
{/* Due Date */}
<div className="space-y-2">
<Label>Échéance</Label>
<DatePicker value={dueDate} onChange={setDueDate} placeholder="Choisir une date" />
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Choisir une date"
/>
</div>
{/* Assignee */}

View file

@ -2,7 +2,11 @@ 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 { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
@ -39,7 +43,12 @@ 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 { GanttChart } from "../components/gantt/GanttChart";
import { TaskModal } from "../components/kanban/TaskModal";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
@ -50,7 +59,12 @@ import { useInviteUser } from "../hooks/invite";
import { useTabloFileNames } from "../hooks/tablo_data";
import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
import { useTabloMembers, useTablosList } from "../hooks/tablos";
import { useAllTasks, useCreateTask, useTabloEtapes } from "../hooks/tasks";
import {
useAllTasks,
useCreateTask,
useTabloEtapes,
useUpdateTask,
} from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
// ─── Icon helpers ─────────────────────────────────────────────────────────────
@ -158,6 +172,9 @@ export const TabloDetailsPage = () => {
const { data: tablos, isLoading } = useTablosList();
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<
Date | undefined
>(undefined);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
@ -179,9 +196,20 @@ 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 openTaskModal = (dueDate?: Date) => {
setTaskModalInitialDueDate(dueDate ? new Date(dueDate) : undefined);
setIsTaskModalOpen(true);
};
const closeTaskModal = () => {
setIsTaskModalOpen(false);
setTaskModalInitialDueDate(undefined);
};
const sectionParam = searchParams.get("section") as TabSection | null;
const activeSection: TabSection =
sectionParam && TABS.some((t) => t.id === sectionParam && !t.disabled)
@ -199,10 +227,11 @@ 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");
}
@ -211,14 +240,18 @@ 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 (
@ -230,7 +263,11 @@ 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);
@ -244,16 +281,22 @@ export const TabloDetailsPage = () => {
<div
className={cn(
"w-12 h-12 rounded-lg flex items-center justify-center shrink-0 overflow-hidden",
!tablo.image && (tablo.color || "bg-gray-400")
!tablo.image && (tablo.color || "bg-gray-400"),
)}
>
{tablo.image ? (
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
) : (
<TabloIcon className={cn("w-6 h-6", iconColor)} />
)}
</div>
<h1 className="text-xl md:text-3xl font-bold text-foreground">{tablo.name}</h1>
<h1 className="text-xl md:text-3xl font-bold text-foreground">
{tablo.name}
</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
@ -281,7 +324,9 @@ export const TabloDetailsPage = () => {
<div className="flex flex-wrap items-center gap-6 text-sm border-b border-[#F2F4F7] dark:border-gray-700 pb-4 mb-4">
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<span className="text-muted-foreground">Rôle :</span>
<span className="text-foreground font-medium">{isAdmin ? "Admin" : "Invité"}</span>
<span className="text-foreground font-medium">
{isAdmin ? "Admin" : "Invité"}
</span>
</div>
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<span className="text-muted-foreground">Créé le :</span>
@ -295,14 +340,22 @@ export const TabloDetailsPage = () => {
</div>
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<span className="text-muted-foreground">Statut :</span>
<span className={cn("px-3 py-1 rounded-full text-xs font-medium", badgeClass)}>
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium",
badgeClass,
)}
>
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Progression :</span>
<div className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: `${progress}%` }} />
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-foreground font-medium">{progress}%</span>
</div>
@ -320,13 +373,15 @@ 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",
)}
>
<tab.icon className="w-4 h-4" />
@ -355,8 +410,9 @@ export const TabloDetailsPage = () => {
Description du projet
</h2>
<p className="text-muted-foreground leading-relaxed text-sm sm:text-base">
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.
</p>
</div>
@ -368,7 +424,7 @@ export const TabloDetailsPage = () => {
</h2>
<button
type="button"
onClick={() => setIsTaskModalOpen(true)}
onClick={() => openTaskModal()}
className="flex items-center justify-center gap-2 px-3 sm:px-4 py-2 bg-[#804EEC] hover:bg-[#6f3fd4] text-white rounded-xl w-full sm:w-auto transition-colors"
>
<PlusIcon className="w-4 h-4" />
@ -397,7 +453,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}
@ -423,7 +479,9 @@ export const TabloDetailsPage = () => {
{/* Files */}
<div className="bg-white dark:bg-card rounded-xl border border-border p-5 sm:p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-foreground">Fichiers</h3>
<h3 className="text-lg font-bold text-foreground">
Fichiers
</h3>
<button
type="button"
onClick={() => setSearchParams({ section: "files" })}
@ -434,7 +492,9 @@ export const TabloDetailsPage = () => {
</div>
<div className="space-y-3">
{fileNames.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun fichier</p>
<p className="text-sm text-muted-foreground">
Aucun fichier
</p>
) : (
fileNames.slice(0, 5).map((fileName) => (
<div
@ -445,7 +505,9 @@ export const TabloDetailsPage = () => {
<FileTextIcon className="w-4 h-4 text-red-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground text-sm truncate">{fileName}</p>
<p className="font-medium text-foreground text-sm truncate">
{fileName}
</p>
</div>
<button
type="button"
@ -461,25 +523,38 @@ export const TabloDetailsPage = () => {
{/* Info */}
<div className="bg-white dark:bg-card rounded-xl border border-border p-5 sm:p-6 shadow-sm">
<h3 className="text-lg font-bold text-foreground mb-4">Informations</h3>
<h3 className="text-lg font-bold text-foreground mb-4">
Informations
</h3>
<dl className="space-y-3 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Tâches</dt>
<dd className="font-medium text-foreground">{tabloTasks.length}</dd>
<dd className="font-medium text-foreground">
{tabloTasks.length}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Fichiers</dt>
<dd className="font-medium text-foreground">{fileNames.length}</dd>
<dd className="font-medium text-foreground">
{fileNames.length}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Statut</dt>
<dd className={cn("px-2 py-0.5 rounded-full text-xs font-medium", badgeClass)}>
<dd
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium",
badgeClass,
)}
>
{statusLabel}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Rôle</dt>
<dd className="font-medium text-foreground">{isAdmin ? "Admin" : "Invité"}</dd>
<dd className="font-medium text-foreground">
{isAdmin ? "Admin" : "Invité"}
</dd>
</div>
</dl>
</div>
@ -487,18 +562,34 @@ export const TabloDetailsPage = () => {
</div>
)}
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "tasks" && (
<TabloTasksSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "files" && (
<TabloFilesSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "discussion" && (
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "etapes" && (
<EtapesSection etapes={etapes} tabloTasks={tabloTasks} tabloId={tabloId ?? ""} />
{activeSection === "events" && (
<TabloEventsSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "roadmap" && <RoadmapSection etapes={etapes} tabloTasks={tabloTasks} />}
{activeSection === "etapes" && (
<EtapesSection
etapes={etapes}
tabloTasks={tabloTasks}
tabloId={tabloId ?? ""}
/>
)}
{activeSection === "roadmap" && (
<RoadmapSection
etapes={etapes}
tabloTasks={tabloTasks}
onDateClick={openTaskModal}
/>
)}
</div>
{/* Task Create Modal */}
@ -506,8 +597,9 @@ export const TabloDetailsPage = () => {
<TaskModal
tabloId={tabloId}
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
onClose={closeTaskModal}
initialStatus="todo"
initialDueDate={taskModalInitialDueDate}
/>
)}
@ -516,7 +608,9 @@ export const TabloDetailsPage = () => {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Partager le projet</DialogTitle>
<DialogDescription>Invitez des personnes à collaborer sur ce projet</DialogDescription>
<DialogDescription>
Invitez des personnes à collaborer sur ce projet
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@ -591,14 +685,18 @@ export const TabloDetailsPage = () => {
<div className="space-y-2 max-h-48 overflow-y-auto">
{filteredMembers.map((member) => {
const isCurrentUser = member.id === currentUser.id;
const avatarUrl = isCurrentUser ? currentUser.avatar_url : null;
const avatarUrl = isCurrentUser
? currentUser.avatar_url
: null;
return (
<div
key={member.id}
className="flex items-center space-x-2 p-2 bg-muted rounded-lg"
>
<Avatar className="w-8 h-8">
{avatarUrl && <AvatarImage src={avatarUrl} alt={member.name} />}
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={member.name} />
)}
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-medium">
{member.name.charAt(0).toUpperCase()}
</AvatarFallback>
@ -636,9 +734,11 @@ function EtapesSection({
tabloId: string;
}) {
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
new Set(etapes.map((e) => e.id))
new Set(etapes.map((e) => e.id)),
);
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(
null,
);
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
const [newTaskTitle, setNewTaskTitle] = useState("");
const { mutate: createTask } = useCreateTask();
@ -673,15 +773,18 @@ function EtapesSection({
},
in_progress: {
label: "En cours",
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
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",
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",
color:
"bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
},
};
@ -689,7 +792,9 @@ function EtapesSection({
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<ListChecksIcon 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 étape</p>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
Aucune étape
</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
Les étapes permettent de structurer votre projet en grandes phases
</p>
@ -700,10 +805,13 @@ function EtapesSection({
return (
<div className="space-y-4">
{etapes.map((etape, index) => {
const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id);
const childTasks = 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 progressPct =
totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
const isExpanded = expandedEtapes.has(etape.id);
// Derive status from child tasks instead of etape.status
@ -756,16 +864,18 @@ function EtapesSection({
className={cn(
"flex items-center gap-1 text-xs shrink-0",
derivedStatus !== "done" &&
new Date(etape.due_date) < new Date(new Date().toDateString())
new Date(etape.due_date) <
new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
: "text-muted-foreground",
)}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>
{new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format(
new Date(etape.due_date)
)}
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(etape.due_date))}
</span>
</div>
)}
@ -773,7 +883,7 @@ function EtapesSection({
<span
className={cn(
"px-2.5 py-1 rounded-full text-xs font-medium shrink-0",
status.color
status.color,
)}
>
{status.label}
@ -814,7 +924,7 @@ function EtapesSection({
"text-sm flex-1 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}
@ -824,9 +934,10 @@ function EtapesSection({
className={cn(
"flex items-center gap-1 text-xs shrink-0",
task.status !== "done" &&
new Date(task.due_date) < new Date(new Date().toDateString())
new Date(task.due_date) <
new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
: "text-muted-foreground",
)}
>
<CalendarIcon className="w-3 h-3" />
@ -842,10 +953,14 @@ function EtapesSection({
<span
className={cn(
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
(statusConfig[task.status] ?? statusConfig.todo).color
(statusConfig[task.status] ?? statusConfig.todo)
.color,
)}
>
{(statusConfig[task.status] ?? statusConfig.todo).label}
{
(statusConfig[task.status] ?? statusConfig.todo)
.label
}
</span>
)}
</div>
@ -922,6 +1037,24 @@ function EtapesSection({
// ─── Roadmap Section ─────────────────────────────────────────────────────────
function RoadmapSection({ tabloTasks }: { etapes: Etape[]; tabloTasks: KanbanTask[] }) {
return <GanttChart tasks={tabloTasks} isLoading={false} />;
function RoadmapSection({
tabloTasks,
onDateClick,
}: {
etapes: Etape[];
tabloTasks: KanbanTask[];
onDateClick: (date: Date) => void;
}) {
const { mutate: updateTask } = useUpdateTask();
return (
<GanttChart
tasks={tabloTasks}
isLoading={false}
onDateClick={onDateClick}
onTaskStatusChange={(taskId, status) =>
updateTask({ id: taskId, status })
}
/>
);
}

View file

@ -114,10 +114,15 @@ export function TasksPage() {
const [statusFilter, setStatusFilter] = useState<TaskStatus>("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<
Date | undefined
>(undefined);
const searchQuery = searchParams.get("q") ?? "";
// Get view mode from URL params, default to "kanban"
const viewMode = (searchParams.get("view") as "kanban" | "aggregated" | "roadmap") || "kanban";
const viewMode =
(searchParams.get("view") as "kanban" | "aggregated" | "roadmap") ||
"kanban";
// Function to update view mode in URL
const setViewMode = (mode: "kanban" | "aggregated" | "roadmap") => {
@ -133,6 +138,16 @@ export function TasksPage() {
// Mutation for updating task status
const updateTaskMutation = useUpdateTask();
const openTaskModal = (dueDate?: Date) => {
setTaskModalInitialDueDate(dueDate ? new Date(dueDate) : undefined);
setIsTaskModalOpen(true);
};
const closeTaskModal = () => {
setIsTaskModalOpen(false);
setTaskModalInitialDueDate(undefined);
};
// Filter and search tasks
const filteredTasks = useMemo(() => {
let filtered = allTasks as TaskWithTablo[];
@ -154,7 +169,9 @@ 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,
);
}
}
@ -163,12 +180,20 @@ 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),
);
}
return filtered;
}, [allTasks, selectedTabloId, statusFilter, assigneeFilter, user.id, searchQuery]);
}, [
allTasks,
selectedTabloId,
statusFilter,
assigneeFilter,
user.id,
searchQuery,
]);
// Initialize Kanban columns from filtered tasks
const columns = useMemo((): KanbanColumn[] => {
@ -239,7 +264,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");
@ -280,7 +305,7 @@ export function TasksPage() {
{t("pages:tasks.title")}
</h1>
<Button
onClick={() => setIsTaskModalOpen(true)}
onClick={() => openTaskModal()}
className="bg-purple-600 hover:bg-purple-700 text-white w-full md:w-auto gap-2"
>
<PlusIcon className="w-4 h-4" />
@ -298,18 +323,23 @@ export function TasksPage() {
type="button"
disabled={tab.disabled}
onClick={() =>
!tab.disabled && setViewMode(tab.id as "kanban" | "aggregated" | "roadmap")
!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",
)}
>
<tab.icon className={twMerge("w-4 h-4", tab.disabled && "opacity-40")} />
<span className={tab.disabled ? "opacity-40" : ""}>{tab.label}</span>
<tab.icon
className={twMerge("w-4 h-4", tab.disabled && "opacity-40")}
/>
<span className={tab.disabled ? "opacity-40" : ""}>
{tab.label}
</span>
{"comingSoon" in tab && tab.comingSoon && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none">
Bientôt
@ -324,7 +354,10 @@ export function TasksPage() {
<div className="flex flex-col md:flex-row md:items-center md:justify-end gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full md:w-auto gap-2 bg-transparent">
<Button
variant="outline"
className="w-full md:w-auto gap-2 bg-transparent"
>
<Settings2Icon className="w-4 h-4" />
Filtrer
</Button>
@ -348,7 +381,7 @@ export function TasksPage() {
<div
className={twMerge(
"w-2 h-2 rounded-full shrink-0",
tablo.color || "bg-gray-400"
tablo.color || "bg-gray-400",
)}
/>
{tablo.name}
@ -405,7 +438,14 @@ export function TasksPage() {
{/* Main Content */}
<main className="px-4 md:px-6 pb-6">
{viewMode === "roadmap" ? (
<RoadmapView tasks={filteredTasks} isLoading={tablosLoading || tasksLoading} />
<RoadmapView
tasks={filteredTasks}
isLoading={tablosLoading || tasksLoading}
onDateClick={openTaskModal}
onTaskStatusChange={(taskId, status) =>
updateTaskMutation.mutate({ id: taskId, status })
}
/>
) : viewMode === "kanban" ? (
/* Kanban Board */
<>
@ -444,7 +484,9 @@ export function TasksPage() {
{/* Column header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<CircleIcon className={`w-5 h-5 ${columnIconColor}`} />
<CircleIcon
className={`w-5 h-5 ${columnIconColor}`}
/>
<h2 className="font-semibold text-gray-800 dark:text-gray-100">
{column.title}
</h2>
@ -454,7 +496,7 @@ export function TasksPage() {
</div>
<button
type="button"
onClick={() => setIsTaskModalOpen(true)}
onClick={() => openTaskModal()}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-1 transition-colors"
>
<PlusIcon className="w-[18px] h-[18px]" />
@ -483,7 +525,8 @@ export function TasksPage() {
const isOverdue =
task.due_date &&
task.status !== "done" &&
new Date(task.due_date) < new Date(new Date().toDateString());
new Date(task.due_date) <
new Date(new Date().toDateString());
return (
<div
@ -508,7 +551,10 @@ export function TasksPage() {
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -518,15 +564,27 @@ export function TasksPage() {
Ouvrir la tâche
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
{(["todo", "in_progress", "in_review", "done"] as const)
<DropdownMenuLabel>
Déplacer vers
</DropdownMenuLabel>
{(
[
"todo",
"in_progress",
"in_review",
"done",
] as const
)
.filter((s) => s !== task.status)
.map((s) => (
<DropdownMenuItem
key={s}
onClick={(e) => {
e.stopPropagation();
updateTaskMutation.mutate({ id: task.id, status: s });
updateTaskMutation.mutate({
id: task.id,
status: s,
});
}}
>
{columnTitles[s]}
@ -542,7 +600,7 @@ export function TasksPage() {
"flex items-center text-xs mb-3",
isOverdue
? "text-red-500"
: "text-gray-500 dark:text-gray-400"
: "text-gray-500 dark:text-gray-400",
)}
>
<CalendarIcon className="w-3.5 h-3.5 mr-1.5" />
@ -553,17 +611,27 @@ export function TasksPage() {
{/* Tablo row */}
{taskWithTablo.tablos &&
(() => {
const TabloIcon = getTabloIcon(taskWithTablo.tablos.color);
const iconColor = getTabloIconColor(taskWithTablo.tablos.color);
const TabloIcon = getTabloIcon(
taskWithTablo.tablos.color,
);
const iconColor = getTabloIconColor(
taskWithTablo.tablos.color,
);
return (
<div className="flex items-center mb-3 border-b border-dashed border-[#D0D5DD] dark:border-gray-600 pb-3">
<div
className={twMerge(
"w-5 h-5 rounded-[5px] mr-2 flex items-center justify-center shrink-0",
taskWithTablo.tablos.color || "bg-gray-400"
taskWithTablo.tablos.color ||
"bg-gray-400",
)}
>
<TabloIcon className={twMerge("w-3 h-3", iconColor)} />
<TabloIcon
className={twMerge(
"w-3 h-3",
iconColor,
)}
/>
</div>
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
{taskWithTablo.tablos.name}
@ -576,10 +644,12 @@ export function TasksPage() {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3 text-gray-500 dark:text-gray-400">
<div className="flex items-center text-xs">
<MessageSquareIcon className="w-3.5 h-3.5 mr-1" />0
<MessageSquareIcon className="w-3.5 h-3.5 mr-1" />
0
</div>
<div className="flex items-center text-xs">
<PaperclipIcon className="w-3.5 h-3.5 mr-1" />0
<PaperclipIcon className="w-3.5 h-3.5 mr-1" />
0
</div>
</div>
@ -594,7 +664,9 @@ export function TasksPage() {
/>
) : (
<div className="w-6 h-6 rounded-full bg-purple-500 border-2 border-white dark:border-gray-800 flex items-center justify-center text-white text-[10px] font-medium">
{task.assignee_name?.charAt(0).toUpperCase() || (
{task.assignee_name
?.charAt(0)
.toUpperCase() || (
<UserIcon className="w-3 h-3" />
)}
</div>
@ -656,7 +728,9 @@ export function TasksPage() {
{/* Column header */}
<div className="px-4 md:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<CircleIcon className={`w-5 h-5 ${columnIconColor}`} />
<CircleIcon
className={`w-5 h-5 ${columnIconColor}`}
/>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
{column.title}
</h3>
@ -666,7 +740,7 @@ export function TasksPage() {
</div>
<button
type="button"
onClick={() => setIsTaskModalOpen(true)}
onClick={() => openTaskModal()}
className="inline-flex items-center gap-1 h-8 px-3 text-xs font-medium rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<PlusIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
@ -727,20 +801,26 @@ export function TasksPage() {
<td className="px-4 md:px-6 py-3">
{taskWithTablo.tablos ? (
(() => {
const TabloIcon = getTabloIcon(taskWithTablo.tablos.color);
const TabloIcon = getTabloIcon(
taskWithTablo.tablos.color,
);
const iconColor = getTabloIconColor(
taskWithTablo.tablos.color
taskWithTablo.tablos.color,
);
return (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div
className={twMerge(
"w-5 h-5 rounded-[4px] shrink-0 flex items-center justify-center",
taskWithTablo.tablos.color || "bg-gray-400"
taskWithTablo.tablos.color ||
"bg-gray-400",
)}
>
<TabloIcon
className={twMerge("w-3 h-3", iconColor)}
className={twMerge(
"w-3 h-3",
iconColor,
)}
/>
</div>
<span className="truncate">
@ -750,7 +830,9 @@ export function TasksPage() {
);
})()
) : (
<span className="text-sm text-gray-400"></span>
<span className="text-sm text-gray-400">
</span>
)}
</td>
@ -768,21 +850,26 @@ export function TasksPage() {
"flex items-center gap-1 text-sm",
dueDateOverdue
? "text-red-500"
: "text-gray-600 dark:text-gray-400"
: "text-gray-600 dark:text-gray-400",
)}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(task.due_date))}
{new Intl.DateTimeFormat(
"fr-FR",
{
day: "2-digit",
month: "short",
},
).format(new Date(task.due_date))}
</span>
</div>
);
})()
) : (
<span className="text-sm text-gray-400"></span>
<span className="text-sm text-gray-400">
</span>
)}
</td>
@ -802,7 +889,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() || (
<UserIcon className="w-3 h-3" />
)}
</div>
@ -827,7 +916,10 @@ export function TasksPage() {
<EllipsisVerticalIcon className="w-4 h-4 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -837,8 +929,17 @@ export function TasksPage() {
Ouvrir la tâche
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
{(["todo", "in_progress", "in_review", "done"] as const)
<DropdownMenuLabel>
Déplacer vers
</DropdownMenuLabel>
{(
[
"todo",
"in_progress",
"in_review",
"done",
] as const
)
.filter((s) => s !== task.status)
.map((s) => (
<DropdownMenuItem
@ -875,10 +976,11 @@ export function TasksPage() {
{/* Task Create Modal */}
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
onClose={closeTaskModal}
tablos={tablos}
allowTabloSelection={true}
initialStatus="todo"
initialDueDate={taskModalInitialDueDate}
/>
</div>
);
@ -889,7 +991,25 @@ export function TasksPage() {
type RoadmapTaskWithTablo = KanbanTask & {
tablos: { id: string; name: string; color: string | null } | null;
};
type RoadmapTaskStatus = "todo" | "in_progress" | "in_review" | "done";
function RoadmapView({ tasks, isLoading }: { tasks: RoadmapTaskWithTablo[]; isLoading: boolean }) {
return <GanttChart tasks={tasks} isLoading={isLoading} />;
function RoadmapView({
tasks,
isLoading,
onDateClick,
onTaskStatusChange,
}: {
tasks: RoadmapTaskWithTablo[];
isLoading: boolean;
onDateClick: (date: Date) => void;
onTaskStatusChange: (taskId: string, status: RoadmapTaskStatus) => void;
}) {
return (
<GanttChart
tasks={tasks}
isLoading={isLoading}
onDateClick={onDateClick}
onTaskStatusChange={onTaskStatusChange}
/>
);
}