Add Étapes (Steps) tab to project detail page

- New "Étapes" tab with ListChecksIcon showing parent tasks (is_parent: true)
- Each étape is an expandable card with: numbered purple icon, title, description, status badge, progress bar (done/total child tasks)
- Expanding reveals child tasks grouped under each étape with done/pending indicators and status badges
- Uses useTabloEtapes hook to fetch parent tasks ordered by position
- Empty state when no étapes exist
- Full dark mode support

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-02-22 09:33:03 +01:00
parent afe47554c8
commit bf9cabe710
No known key found for this signature in database

View file

@ -1,15 +1,18 @@
import { cn, toast } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { KanbanTask } from "@xtablo/shared-types";
import type { Etape, KanbanTask } from "@xtablo/shared-types";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import {
CalendarIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleCheckIcon,
EllipsisVerticalIcon,
FileTextIcon,
FolderIcon,
KanbanIcon,
LayoutDashboardIcon,
ListChecksIcon,
MapIcon,
MessageCircleIcon,
PlusIcon,
@ -26,7 +29,7 @@ import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloTasksSection } from "../components/TabloTasksSection";
import { useAllTasks } from "../hooks/tasks";
import { useAllTasks, useTabloEtapes } from "../hooks/tasks";
import { useTabloFileNames } from "../hooks/tablo_data";
import { useTablosList } from "../hooks/tablos";
@ -69,7 +72,8 @@ type TabSection =
| "files"
| "discussion"
| "events"
| "tasks";
| "tasks"
| "etapes";
const TABS: {
id: TabSection;
@ -78,6 +82,7 @@ const TABS: {
disabled?: boolean;
}[] = [
{ id: "overview", label: "Aperçu", icon: LayoutDashboardIcon },
{ id: "etapes", label: "Étapes", icon: ListChecksIcon },
{ id: "tasks", label: "Tâches", icon: KanbanIcon },
{ id: "files", label: "Fichiers", icon: FolderIcon },
{ id: "discussion", label: "Discussion", icon: MessageCircleIcon },
@ -127,6 +132,9 @@ export const TabloDetailsPage = () => {
(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(
@ -449,7 +457,153 @@ export const TabloDetailsPage = () => {
{activeSection === "events" && (
<TabloEventsSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "etapes" && (
<EtapesSection etapes={etapes} tabloTasks={tabloTasks} />
)}
</div>
</div>
);
};
// ─── Etapes (Steps) section ─────────────────────────────────────────────────
function EtapesSection({
etapes,
tabloTasks,
}: {
etapes: Etape[];
tabloTasks: KanbanTask[];
}) {
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
new Set(etapes.map((e) => e.id)),
);
const toggleEtape = (id: string) => {
setExpandedEtapes((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
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" },
};
if (etapes.length === 0) {
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-400 dark:text-gray-500 text-sm mt-1">Les étapes permettent de structurer votre projet en grandes phases</p>
</div>
);
}
return (
<div className="space-y-4">
{etapes.map((etape, index) => {
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 isExpanded = expandedEtapes.has(etape.id);
const status = statusConfig[etape.status] ?? 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 */}
<button
type="button"
onClick={() => toggleEtape(etape.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">
<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>
<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>
)}
</button>
{/* Child tasks */}
{isExpanded && 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) => (
<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.status && (
<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>
)}
</div>
))}
</div>
)}
{isExpanded && childTasks.length === 0 && (
<div className="border-t border-gray-100 dark:border-gray-700 px-5 py-4 pl-16 text-sm text-muted-foreground">
Aucune tâche dans cette étape
</div>
)}
</div>
);
})}
</div>
);
}