feat(tasks): roadmap click-to-create with due date and quick status update
This commit is contained in:
parent
349ba4ae7c
commit
421676c3f0
4 changed files with 550 additions and 160 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue