Add due_date field to tasks and implement roadmap feature
- Add due_date column to tasks table with Supabase migration - Update database types and tasks_with_assignee view - Add DatePicker to TaskModal for setting due dates - Display due dates on KanbanTaskCard, list view, and Etapes section - Enable Roadmap tab on both Tasks page and Tablo Details page - Add RoadmapView components with timeline grouped by Etape - Highlight overdue dates in red across all views Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bec48cfab6
commit
2b09dd4093
7 changed files with 994 additions and 244 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import type { KanbanTask } from "@xtablo/shared-types";
|
||||
import { TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { User } from "lucide-react";
|
||||
import { CalendarIcon, User } from "lucide-react";
|
||||
|
||||
interface KanbanTaskCardProps {
|
||||
task: KanbanTask;
|
||||
|
|
@ -8,7 +8,24 @@ interface KanbanTaskCardProps {
|
|||
onClick: () => void;
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function isOverdue(dateStr: string): boolean {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const due = new Date(dateStr);
|
||||
return due < today;
|
||||
}
|
||||
|
||||
export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => {
|
||||
const overdue = task.due_date && task.status !== "done" && isOverdue(task.due_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
|
|
@ -24,6 +41,16 @@ export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProp
|
|||
</TypographyMuted>
|
||||
)}
|
||||
|
||||
{/* Due date */}
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs mb-2 ${overdue ? "text-red-500" : "text-muted-foreground"}`}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>{formatDueDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Pill */}
|
||||
<div className="mb-2">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, TaskStatus } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { DatePicker } from "@xtablo/ui/components/date-picker";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import {
|
||||
|
|
@ -13,10 +15,9 @@ import { Textarea } from "@xtablo/ui/components/textarea";
|
|||
import { TypographyH2 } from "@xtablo/ui/components/typography";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCreateTask, useTask, useUpdateTask, useTabloEtapes } from "../../hooks/tasks";
|
||||
import { useTabloMembers } from "../../hooks/tablos";
|
||||
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
|
||||
import type { TabloMember } from "./types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -46,6 +47,7 @@ export const TaskModal = ({
|
|||
const [description, setDescription] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
|
||||
const [etapeId, setEtapeId] = useState<string>("none");
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(
|
||||
initialTabloId || tablos?.[0]?.id || ""
|
||||
);
|
||||
|
|
@ -68,6 +70,7 @@ export const TaskModal = ({
|
|||
setDescription(task.description ?? "");
|
||||
setAssigneeId(task.assignee_id ?? "unassigned");
|
||||
setEtapeId(task.parent_task_id ?? "none");
|
||||
setDueDate(task.due_date ? new Date(task.due_date) : undefined);
|
||||
if (!initialTabloId && task.tablo_id) {
|
||||
setSelectedTabloId(task.tablo_id);
|
||||
}
|
||||
|
|
@ -77,6 +80,7 @@ export const TaskModal = ({
|
|||
setDescription("");
|
||||
setAssigneeId("unassigned");
|
||||
setEtapeId("none");
|
||||
setDueDate(undefined);
|
||||
if (allowTabloSelection && tablos && tablos.length > 0) {
|
||||
setSelectedTabloId(tablos[0].id);
|
||||
}
|
||||
|
|
@ -86,11 +90,22 @@ export const TaskModal = ({
|
|||
const { mutate: createTask } = useCreateTask();
|
||||
const { mutate: updateTask } = useUpdateTask();
|
||||
|
||||
// Format Date to YYYY-MM-DD string for database storage
|
||||
const formatDateForDb = (date: Date | undefined): string | null => {
|
||||
if (!date) return null;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (!currentTabloId) return; // Need a tablo to create task
|
||||
|
||||
const dueDateValue = formatDateForDb(dueDate);
|
||||
|
||||
if (taskId && task) {
|
||||
updateTask({
|
||||
tablo_id: task.tablo_id,
|
||||
|
|
@ -100,6 +115,7 @@ export const TaskModal = ({
|
|||
assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
|
||||
status: initialStatus,
|
||||
parent_task_id: etapeId !== "none" ? etapeId : null,
|
||||
due_date: dueDateValue,
|
||||
});
|
||||
} else {
|
||||
createTask({
|
||||
|
|
@ -109,6 +125,7 @@ export const TaskModal = ({
|
|||
assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
|
||||
status: initialStatus,
|
||||
parent_task_id: etapeId !== "none" ? etapeId : null,
|
||||
due_date: dueDateValue,
|
||||
});
|
||||
}
|
||||
// Reset form
|
||||
|
|
@ -116,6 +133,7 @@ export const TaskModal = ({
|
|||
setDescription("");
|
||||
setAssigneeId("unassigned");
|
||||
setEtapeId("none");
|
||||
setDueDate(undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -188,22 +206,11 @@ export const TaskModal = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
{/* <div className="space-y-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
|
||||
<SelectTrigger id="type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="task">Task</SelectItem>
|
||||
<SelectItem value="story">Story</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
<SelectItem value="epic">Epic</SelectItem>
|
||||
<SelectItem value="subtask">Subtask</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div> */}
|
||||
{/* Due Date */}
|
||||
<div className="space-y-2">
|
||||
<Label>Échéance</Label>
|
||||
<DatePicker value={dueDate} onChange={setDueDate} placeholder="Choisir une date" />
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type CreateEtapeInput = {
|
|||
title: string;
|
||||
description?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
};
|
||||
|
||||
type UpdateEtapeInput = {
|
||||
|
|
@ -23,6 +24,7 @@ type UpdateEtapeInput = {
|
|||
title?: string;
|
||||
description?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
};
|
||||
|
||||
type DeleteEtapeInput = {
|
||||
|
|
@ -169,6 +171,7 @@ export const useCreateTask = () => {
|
|||
position: task.position || 0,
|
||||
parent_task_id: task.parent_task_id ?? null,
|
||||
is_parent: task.is_parent ?? false,
|
||||
due_date: task.due_date ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
|
@ -358,7 +361,7 @@ export const useCreateEtape = () => {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tabloId, title, description, position }: CreateEtapeInput) => {
|
||||
mutationFn: async ({ tabloId, title, description, position, due_date }: CreateEtapeInput) => {
|
||||
const { data, error } = await supabase
|
||||
.from("tasks")
|
||||
.insert({
|
||||
|
|
@ -368,6 +371,7 @@ export const useCreateEtape = () => {
|
|||
status: "todo",
|
||||
position: position ?? 0,
|
||||
is_parent: true,
|
||||
due_date: due_date ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
|
@ -404,11 +408,19 @@ export const useUpdateEtape = () => {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, tabloId, title, description, position }: UpdateEtapeInput) => {
|
||||
mutationFn: async ({
|
||||
id,
|
||||
tabloId,
|
||||
title,
|
||||
description,
|
||||
position,
|
||||
due_date,
|
||||
}: UpdateEtapeInput) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (position !== undefined) updates.position = position;
|
||||
if (due_date !== undefined) updates.due_date = due_date;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error("Aucune modification fournie pour l'Étape");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { cn, toast } from "@xtablo/shared";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, KanbanTask } from "@xtablo/shared-types";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -39,39 +39,45 @@ import {
|
|||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { TaskModal } from "../components/kanban/TaskModal";
|
||||
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
|
||||
import { TabloEventsSection } from "../components/TabloEventsSection";
|
||||
import { TabloFilesSection } from "../components/TabloFilesSection";
|
||||
import { TabloTasksSection } from "../components/TabloTasksSection";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useAllTasks, useCreateTask, useTabloEtapes } from "../hooks/tasks";
|
||||
import { useTabloFileNames } from "../hooks/tablo_data";
|
||||
import { useTablosList, useTabloMembers } from "../hooks/tablos";
|
||||
import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useTabloMembers, useTablosList } from "../hooks/tablos";
|
||||
import { useAllTasks, useCreateTask, useTabloEtapes } from "../hooks/tasks";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
// ─── Icon helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
case "bg-blue-500":
|
||||
return Zap;
|
||||
case "bg-green-500":
|
||||
return Leaf;
|
||||
case "bg-purple-500":
|
||||
return Gem;
|
||||
case "bg-red-500":
|
||||
return Flame;
|
||||
case "bg-yellow-500":
|
||||
return Star;
|
||||
case "bg-indigo-500":
|
||||
return Compass;
|
||||
case "bg-pink-500":
|
||||
return Heart;
|
||||
case "bg-teal-500":
|
||||
return Waves;
|
||||
case "bg-orange-500":
|
||||
return Sun;
|
||||
case "bg-cyan-500":
|
||||
return Sparkles;
|
||||
default:
|
||||
return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +145,7 @@ const TABS: {
|
|||
{ id: "files", label: "Fichiers", icon: FolderIcon },
|
||||
{ id: "discussion", label: "Discussion", icon: MessageCircleIcon },
|
||||
{ id: "events", label: "Événements", icon: CalendarIcon },
|
||||
{ id: "roadmap", label: "Roadmap", icon: MapIcon, disabled: true },
|
||||
{ id: "roadmap", label: "Roadmap", icon: MapIcon },
|
||||
];
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -172,7 +178,7 @@ export const TabloDetailsPage = () => {
|
|||
};
|
||||
|
||||
const filteredMembers = members?.filter(
|
||||
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email),
|
||||
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
|
||||
);
|
||||
|
||||
const sectionParam = searchParams.get("section") as TabSection | null;
|
||||
|
|
@ -192,11 +198,10 @@ export const TabloDetailsPage = () => {
|
|||
toast.add(
|
||||
{
|
||||
title: "Projet introuvable",
|
||||
description:
|
||||
"Le projet demandé n'existe pas ou vous n'y avez pas accès",
|
||||
description: "Le projet demandé n'existe pas ou vous n'y avez pas accès",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
navigate("/tablos");
|
||||
}
|
||||
|
|
@ -205,18 +210,14 @@ export const TabloDetailsPage = () => {
|
|||
|
||||
// Tasks for this tablo (used in overview)
|
||||
const { data: allTasks = [] } = useAllTasks();
|
||||
const tabloTasks = (allTasks as KanbanTask[]).filter(
|
||||
(t) => t.tablo_id === tabloId,
|
||||
);
|
||||
const tabloTasks = (allTasks as KanbanTask[]).filter((t) => t.tablo_id === tabloId);
|
||||
|
||||
// Etapes (parent tasks) for this tablo
|
||||
const { data: etapes = [] } = useTabloEtapes(tabloId);
|
||||
|
||||
// Files for this tablo (used in overview)
|
||||
const { data: filesData } = useTabloFileNames(tabloId ?? "");
|
||||
const fileNames = (filesData?.fileNames ?? []).filter(
|
||||
(f) => !f.startsWith("."),
|
||||
);
|
||||
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -228,11 +229,7 @@ export const TabloDetailsPage = () => {
|
|||
|
||||
if (!tablo) return null;
|
||||
|
||||
const {
|
||||
label: statusLabel,
|
||||
badgeClass,
|
||||
progress,
|
||||
} = getStatusConfig(tablo.status);
|
||||
const { label: statusLabel, badgeClass, progress } = getStatusConfig(tablo.status);
|
||||
const isAdmin = tablo.is_admin;
|
||||
const TabloIcon = getTabloIcon(tablo.color);
|
||||
const iconColor = getTabloIconColor(tablo.color);
|
||||
|
|
@ -246,22 +243,16 @@ export const TabloDetailsPage = () => {
|
|||
<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">
|
||||
|
|
@ -289,9 +280,7 @@ 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>
|
||||
|
|
@ -305,22 +294,14 @@ 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>
|
||||
|
|
@ -338,15 +319,13 @@ export const TabloDetailsPage = () => {
|
|||
key={tab.id}
|
||||
type="button"
|
||||
disabled={tab.disabled}
|
||||
onClick={() =>
|
||||
!tab.disabled && setSearchParams({ section: tab.id })
|
||||
}
|
||||
onClick={() => !tab.disabled && setSearchParams({ section: tab.id })}
|
||||
className={cn(
|
||||
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
|
||||
isActive
|
||||
? "text-[#804EEC] border-[#804EEC]"
|
||||
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
|
||||
tab.disabled && "opacity-40 cursor-not-allowed",
|
||||
tab.disabled && "opacity-40 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
|
|
@ -375,9 +354,8 @@ 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>
|
||||
|
||||
|
|
@ -418,7 +396,7 @@ export const TabloDetailsPage = () => {
|
|||
"text-sm font-medium truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100",
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
|
|
@ -444,9 +422,7 @@ export const TabloDetailsPage = () => {
|
|||
{/* Files */}
|
||||
<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" })}
|
||||
|
|
@ -457,9 +433,7 @@ 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
|
||||
|
|
@ -470,9 +444,7 @@ 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"
|
||||
|
|
@ -488,38 +460,25 @@ 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>
|
||||
|
|
@ -527,22 +486,18 @@ 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 === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
|
||||
{activeSection === "etapes" && (
|
||||
<EtapesSection etapes={etapes} tabloTasks={tabloTasks} tabloId={tabloId ?? ""} />
|
||||
)}
|
||||
|
||||
{activeSection === "roadmap" && <RoadmapSection etapes={etapes} tabloTasks={tabloTasks} />}
|
||||
</div>
|
||||
|
||||
{/* Task Create Modal */}
|
||||
|
|
@ -601,8 +556,18 @@ export const TabloDetailsPage = () => {
|
|||
className="flex items-center space-x-2 p-2 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-xs">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -670,7 +635,7 @@ 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 [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
|
|
@ -701,10 +666,22 @@ function EtapesSection({
|
|||
};
|
||||
|
||||
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" },
|
||||
todo: {
|
||||
label: "À faire",
|
||||
color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
|
||||
},
|
||||
in_progress: {
|
||||
label: "En cours",
|
||||
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
},
|
||||
in_review: {
|
||||
label: "Vérification",
|
||||
color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
},
|
||||
done: {
|
||||
label: "Terminé",
|
||||
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
|
||||
},
|
||||
};
|
||||
|
||||
if (etapes.length === 0) {
|
||||
|
|
@ -712,7 +689,9 @@ function EtapesSection({
|
|||
<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-400 dark:text-gray-500 text-sm mt-1">Les étapes permettent de structurer votre projet en grandes phases</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -771,14 +750,41 @@ function EtapesSection({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<span className={cn("px-2.5 py-1 rounded-full text-xs font-medium shrink-0", status.color)}>
|
||||
{etape.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
derivedStatus !== "done" &&
|
||||
new Date(etape.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "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)
|
||||
)}
|
||||
</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
|
||||
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}
|
||||
|
|
@ -805,13 +811,39 @@ function EtapesSection({
|
|||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done" ? "line-through text-gray-400" : "text-gray-900 dark:text-gray-100",
|
||||
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",
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(task.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status && (
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0", (statusConfig[task.status] ?? statusConfig.todo).color)}>
|
||||
<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).label}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -837,7 +869,10 @@ function EtapesSection({
|
|||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddTask(etape.id);
|
||||
if (e.key === "Escape") { setAddingTaskToEtape(null); setNewTaskTitle(""); }
|
||||
if (e.key === "Escape") {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder="Nom de la tâche..."
|
||||
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
|
|
@ -852,7 +887,10 @@ function EtapesSection({
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingTaskToEtape(null); setNewTaskTitle(""); }}
|
||||
onClick={() => {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
|
||||
>
|
||||
Annuler
|
||||
|
|
@ -861,7 +899,11 @@ function EtapesSection({
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setAddingTaskToEtape(etape.id); setNewTaskTitle(""); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingTaskToEtape(etape.id);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2.5 pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
|
|
@ -876,3 +918,271 @@ function EtapesSection({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types";
|
|||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from "@xtablo/ui/components/dropdown-menu";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleCheckIcon,
|
||||
CircleIcon,
|
||||
Compass,
|
||||
|
|
@ -22,10 +24,11 @@ import {
|
|||
Heart,
|
||||
KanbanIcon,
|
||||
Leaf,
|
||||
ListChecksIcon,
|
||||
ListIcon,
|
||||
ListTodo,
|
||||
MessageSquareIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
Settings2Icon,
|
||||
|
|
@ -40,10 +43,10 @@ import { useMemo, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { TaskModal } from "../components/kanban/TaskModal";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
type TaskStatus = "all" | "todo" | "in_progress" | "in_review" | "done";
|
||||
|
||||
|
|
@ -68,17 +71,28 @@ const columnTitles = {
|
|||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
case "bg-blue-500":
|
||||
return Zap;
|
||||
case "bg-green-500":
|
||||
return Leaf;
|
||||
case "bg-purple-500":
|
||||
return Gem;
|
||||
case "bg-red-500":
|
||||
return Flame;
|
||||
case "bg-yellow-500":
|
||||
return Star;
|
||||
case "bg-indigo-500":
|
||||
return Compass;
|
||||
case "bg-pink-500":
|
||||
return Heart;
|
||||
case "bg-teal-500":
|
||||
return Waves;
|
||||
case "bg-orange-500":
|
||||
return Sun;
|
||||
case "bg-cyan-500":
|
||||
return Sparkles;
|
||||
default:
|
||||
return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,11 +119,10 @@ export function TasksPage() {
|
|||
const searchQuery = searchParams.get("q") ?? "";
|
||||
|
||||
// Get view mode from URL params, default to "kanban"
|
||||
const viewMode =
|
||||
(searchParams.get("view") as "kanban" | "aggregated") || "kanban";
|
||||
const viewMode = (searchParams.get("view") as "kanban" | "aggregated" | "roadmap") || "kanban";
|
||||
|
||||
// Function to update view mode in URL
|
||||
const setViewMode = (mode: "kanban" | "aggregated") => {
|
||||
const setViewMode = (mode: "kanban" | "aggregated" | "roadmap") => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("view", mode);
|
||||
setSearchParams(newParams);
|
||||
|
|
@ -143,9 +156,7 @@ export function TasksPage() {
|
|||
} else if (assigneeFilter === "unassigned") {
|
||||
filtered = filtered.filter((task) => !task.assignee_id);
|
||||
} else {
|
||||
filtered = filtered.filter(
|
||||
(task) => task.assignee_id === assigneeFilter,
|
||||
);
|
||||
filtered = filtered.filter((task) => task.assignee_id === assigneeFilter);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,8 +165,7 @@ export function TasksPage() {
|
|||
const q = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(task) =>
|
||||
task.title?.toLowerCase().includes(q) ||
|
||||
task.description?.toLowerCase().includes(q),
|
||||
task.title?.toLowerCase().includes(q) || task.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +241,7 @@ export function TasksPage() {
|
|||
|
||||
const handleDrop = (
|
||||
e: React.DragEvent,
|
||||
targetStatus: "todo" | "in_progress" | "in_review" | "done",
|
||||
targetStatus: "todo" | "in_progress" | "in_review" | "done"
|
||||
) => {
|
||||
e.preventDefault();
|
||||
const taskId = e.dataTransfer.getData("taskId");
|
||||
|
|
@ -252,13 +262,7 @@ export function TasksPage() {
|
|||
const viewTabs = [
|
||||
{ id: "kanban" as const, label: "Tableau", icon: KanbanIcon },
|
||||
{ id: "aggregated" as const, label: "Liste", icon: ListIcon },
|
||||
{
|
||||
id: "gantt" as const,
|
||||
label: "Roadmap",
|
||||
icon: MapIcon,
|
||||
disabled: true,
|
||||
comingSoon: true,
|
||||
},
|
||||
{ id: "roadmap" as const, label: "Roadmap", icon: MapIcon },
|
||||
{
|
||||
id: "calendar" as const,
|
||||
label: "Calendrier",
|
||||
|
|
@ -296,15 +300,14 @@ export function TasksPage() {
|
|||
type="button"
|
||||
disabled={tab.disabled}
|
||||
onClick={() =>
|
||||
!tab.disabled &&
|
||||
setViewMode(tab.id as "kanban" | "aggregated")
|
||||
!tab.disabled && setViewMode(tab.id as "kanban" | "aggregated" | "roadmap")
|
||||
}
|
||||
className={twMerge(
|
||||
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
|
||||
isActive
|
||||
? "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400"
|
||||
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
|
||||
tab.disabled && "cursor-not-allowed",
|
||||
tab.disabled && "cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<tab.icon className={twMerge("w-4 h-4", tab.disabled && "opacity-40")} />
|
||||
|
|
@ -323,10 +326,7 @@ 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>
|
||||
|
|
@ -350,7 +350,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}
|
||||
|
|
@ -406,7 +406,9 @@ export function TasksPage() {
|
|||
|
||||
{/* Main Content */}
|
||||
<main className="px-4 md:px-6 pb-6">
|
||||
{viewMode === "kanban" ? (
|
||||
{viewMode === "roadmap" ? (
|
||||
<RoadmapView tasks={filteredTasks} isLoading={tablosLoading || tasksLoading} />
|
||||
) : viewMode === "kanban" ? (
|
||||
/* Kanban Board */
|
||||
<>
|
||||
{tablosLoading || tasksLoading ? (
|
||||
|
|
@ -444,9 +446,7 @@ 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>
|
||||
|
|
@ -476,13 +476,16 @@ export function TasksPage() {
|
|||
) : (
|
||||
column.tasks.map((task) => {
|
||||
const taskWithTablo = task as TaskWithTablo;
|
||||
// const formattedDate = task.due_date
|
||||
// ? new Intl.DateTimeFormat("en-US", {
|
||||
// month: "short",
|
||||
// day: "2-digit",
|
||||
// year: "numeric",
|
||||
// }).format(new Date(task.due_date))
|
||||
// : null;
|
||||
const formattedDate = task.due_date
|
||||
? new Intl.DateTimeFormat("fr-FR", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
}).format(new Date(task.due_date))
|
||||
: null;
|
||||
const isOverdue =
|
||||
task.due_date &&
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) < new Date(new Date().toDateString());
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -508,7 +511,12 @@ export function TasksPage() {
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTaskClick(task);
|
||||
}}
|
||||
>
|
||||
Ouvrir la tâche
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -530,46 +538,50 @@ export function TasksPage() {
|
|||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Due date — commented out until field is available
|
||||
{formattedDate && (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center text-xs mb-3",
|
||||
isOverdue
|
||||
? "text-red-500"
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Tablo row */}
|
||||
{taskWithTablo.tablos && (() => {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<TabloIcon className={twMerge("w-3 h-3", iconColor)} />
|
||||
{taskWithTablo.tablos &&
|
||||
(() => {
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<TabloIcon className={twMerge("w-3 h-3", iconColor)} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
{taskWithTablo.tablos.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
{taskWithTablo.tablos.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Footer: stats + assignee */}
|
||||
<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>
|
||||
|
||||
|
|
@ -584,9 +596,7 @@ 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>
|
||||
|
|
@ -632,20 +642,26 @@ export function TasksPage() {
|
|||
<div className="space-y-6">
|
||||
{columns.map((column) => {
|
||||
if (column.tasks.length === 0) return null;
|
||||
const columnIconColor = {
|
||||
todo: "text-gray-400",
|
||||
in_progress: "text-yellow-500",
|
||||
in_review: "text-blue-500",
|
||||
done: "text-green-500",
|
||||
}[column.status] ?? "text-gray-400";
|
||||
const columnIconColor =
|
||||
{
|
||||
todo: "text-gray-400",
|
||||
in_progress: "text-yellow-500",
|
||||
in_review: "text-blue-500",
|
||||
done: "text-green-500",
|
||||
}[column.status] ?? "text-gray-400";
|
||||
|
||||
return (
|
||||
<div key={column.id} className="bg-[#F9FAFB] dark:bg-gray-800/60 rounded-lg p-2">
|
||||
<div
|
||||
key={column.id}
|
||||
className="bg-[#F9FAFB] dark:bg-gray-800/60 rounded-lg p-2"
|
||||
>
|
||||
{/* 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}`} />
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{column.title}</h3>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{column.title}
|
||||
</h3>
|
||||
<span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded-full">
|
||||
{column.tasks.length}
|
||||
</span>
|
||||
|
|
@ -661,18 +677,28 @@ export function TasksPage() {
|
|||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full table-fixed min-w-[600px]">
|
||||
<table className="w-full table-fixed min-w-[700px]">
|
||||
<colgroup>
|
||||
<col className="w-[45%]" />
|
||||
<col className="w-[35%]" />
|
||||
<col className="w-[25%]" />
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[12%]" />
|
||||
<col className="w-[8%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">TÂCHE</th>
|
||||
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">PROJET</th>
|
||||
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">PERSONNES</th>
|
||||
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">
|
||||
TÂCHE
|
||||
</th>
|
||||
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">
|
||||
PROJET
|
||||
</th>
|
||||
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">
|
||||
ÉCHÉANCE
|
||||
</th>
|
||||
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">
|
||||
PERSONNES
|
||||
</th>
|
||||
<th className="px-4 md:px-6 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-400" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -701,21 +727,63 @@ export function TasksPage() {
|
|||
|
||||
{/* Project */}
|
||||
<td className="px-4 md:px-6 py-3">
|
||||
{taskWithTablo.tablos ? (() => {
|
||||
{taskWithTablo.tablos ? (
|
||||
(() => {
|
||||
const TabloIcon = getTabloIcon(taskWithTablo.tablos.color);
|
||||
const iconColor = getTabloIconColor(taskWithTablo.tablos.color);
|
||||
const iconColor = getTabloIconColor(
|
||||
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"
|
||||
)}>
|
||||
<TabloIcon className={twMerge("w-3 h-3", iconColor)} />
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-[4px] shrink-0 flex items-center justify-center",
|
||||
taskWithTablo.tablos.color || "bg-gray-400"
|
||||
)}
|
||||
>
|
||||
<TabloIcon
|
||||
className={twMerge("w-3 h-3", iconColor)}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">{taskWithTablo.tablos.name}</span>
|
||||
<span className="truncate">
|
||||
{taskWithTablo.tablos.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
})()
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Due date */}
|
||||
<td className="px-4 md:px-6 py-3">
|
||||
{task.due_date ? (
|
||||
(() => {
|
||||
const dueDateOverdue =
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) <
|
||||
new Date(new Date().toDateString());
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center gap-1 text-sm",
|
||||
dueDateOverdue
|
||||
? "text-red-500"
|
||||
: "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))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -736,7 +804,9 @@ export function TasksPage() {
|
|||
title={task.assignee_name || ""}
|
||||
className="w-6 h-6 rounded-full bg-purple-200 dark:bg-purple-900 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold border border-white dark:border-gray-800"
|
||||
>
|
||||
{task.assignee_name?.charAt(0).toUpperCase() || <UserIcon className="w-3 h-3" />}
|
||||
{task.assignee_name?.charAt(0).toUpperCase() || (
|
||||
<UserIcon className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
|
@ -760,7 +830,12 @@ export function TasksPage() {
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTaskClick(task);
|
||||
}}
|
||||
>
|
||||
Ouvrir la tâche
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -772,7 +847,10 @@ export function TasksPage() {
|
|||
key={s}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateTaskMutation.mutate({ id: task.id, status: s });
|
||||
updateTaskMutation.mutate({
|
||||
id: task.id,
|
||||
status: s,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{columnTitles[s]}
|
||||
|
|
@ -807,3 +885,283 @@ export function TasksPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -607,6 +607,7 @@ export type Database = {
|
|||
assignee_id: string | null;
|
||||
created_at: string;
|
||||
description: string | null;
|
||||
due_date: string | null;
|
||||
id: string;
|
||||
is_parent: boolean;
|
||||
parent_task_id: string | null;
|
||||
|
|
@ -620,6 +621,7 @@ export type Database = {
|
|||
assignee_id?: string | null;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
due_date?: string | null;
|
||||
id?: string;
|
||||
is_parent?: boolean;
|
||||
parent_task_id?: string | null;
|
||||
|
|
@ -633,6 +635,7 @@ export type Database = {
|
|||
assignee_id?: string | null;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
due_date?: string | null;
|
||||
id?: string;
|
||||
is_parent?: boolean;
|
||||
parent_task_id?: string | null;
|
||||
|
|
@ -725,6 +728,7 @@ export type Database = {
|
|||
assignee_name: string | null;
|
||||
created_at: string | null;
|
||||
description: string | null;
|
||||
due_date: string | null;
|
||||
id: string | null;
|
||||
is_parent: boolean | null;
|
||||
parent_task_id: string | null;
|
||||
|
|
|
|||
32
supabase/migrations/20260224000000_add_due_date_to_tasks.sql
Normal file
32
supabase/migrations/20260224000000_add_due_date_to_tasks.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- Add due_date column to tasks table for roadmap feature
|
||||
ALTER TABLE "public"."tasks"
|
||||
ADD COLUMN "due_date" date;
|
||||
|
||||
COMMENT ON COLUMN "public"."tasks"."due_date" IS 'Optional due date for tasks and Etapes, used in roadmap views';
|
||||
|
||||
-- Index for roadmap queries (filtering/sorting by due_date within a tablo)
|
||||
CREATE INDEX "tasks_tablo_due_date_idx" ON "public"."tasks" USING btree ("tablo_id", "due_date");
|
||||
|
||||
-- Update tasks_with_assignee view to include due_date
|
||||
CREATE OR REPLACE VIEW "public"."tasks_with_assignee" WITH ("security_invoker"='true') AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.tablo_id,
|
||||
t.title,
|
||||
t.description,
|
||||
t.status,
|
||||
t.assignee_id,
|
||||
t.position,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
p.name AS assignee_name,
|
||||
p.avatar_url AS assignee_avatar,
|
||||
t.is_parent,
|
||||
t.parent_task_id,
|
||||
t.due_date
|
||||
FROM "public"."tasks" t
|
||||
LEFT JOIN "public"."profiles" p ON t.assignee_id = p.id;
|
||||
|
||||
ALTER TABLE "public"."tasks_with_assignee" OWNER TO "postgres";
|
||||
|
||||
COMMENT ON VIEW "public"."tasks_with_assignee" IS 'View that returns tasks with assignee information from profiles';
|
||||
Loading…
Reference in a new issue