Merge pull request #77 from artslidd/parity-with-client

Parity with client
This commit is contained in:
Arthur Belleville 2026-04-16 11:12:23 +02:00 committed by GitHub
commit b75e5e658f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 516 additions and 655 deletions

View file

@ -6,6 +6,8 @@ import { TabloDetailsPage } from "./tablo-details";
const mutateUpdateTablo = vi.fn();
const mutateUpdateTask = vi.fn();
const mutateCreateEtape = vi.fn();
const mutateDeleteTask = vi.fn();
const tablosData = [
{
@ -110,11 +112,14 @@ vi.mock("../hooks/tasks", () => ({
useUpdateTask: () => ({
mutate: mutateUpdateTask,
}),
useDeleteTask: () => ({
mutate: mutateDeleteTask,
}),
useTask: () => ({
data: null,
}),
useCreateEtape: () => ({
mutateAsync: vi.fn(),
mutateAsync: mutateCreateEtape,
isPending: false,
}),
useCreateTask: () => ({
@ -174,6 +179,54 @@ vi.mock("../providers/UserStoreProvider", async (importOriginal) => {
});
describe("TabloDetailsPage overview layout", () => {
it("keeps the add etape action enabled before typing", async () => {
const user = userEvent.setup();
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",
path: "/tablos/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Étapes" }));
expect(screen.getByRole("button", { name: "Ajouter une étape" })).toBeEnabled();
});
it("creates an etape from the etapes tab", async () => {
const user = userEvent.setup();
mutateCreateEtape.mockResolvedValueOnce(undefined);
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",
path: "/tablos/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Étapes" }));
await user.type(screen.getByPlaceholderText("Nom de la nouvelle étape..."), "Kickoff");
await user.click(screen.getByRole("button", { name: "Ajouter une étape" }));
expect(mutateCreateEtape).toHaveBeenCalledWith({
tabloId: "tablo-1",
title: "Kickoff",
position: 0,
});
});
it("deletes a task from the task modal", async () => {
const user = userEvent.setup();
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",
path: "/tablos/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Tâches" }));
await user.click(screen.getByText("Task A"));
await user.click(screen.getByRole("button", { name: "Supprimer la tâche" }));
expect(mutateDeleteTask).toHaveBeenCalledWith("task-1");
});
it("renders overview cards in persisted left-zone order", () => {
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",

View file

@ -1,15 +1,24 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { cn, toast } from "@xtablo/shared";
import { toast } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { KanbanTask } from "@xtablo/shared-types";
import {
DEFAULT_OVERVIEW_LAYOUT,
EtapesSection,
type OverviewLayoutV1,
RoadmapSection,
SingleTabloOverview,
SingleTabloView,
TabloDiscussionSection,
TabloEventsSection,
TabloFilesSection,
TabloTasksSection,
TaskModal,
getSingleTabloStatusConfig,
moveBetweenZones,
moveWithinZone,
sanitizeOverviewLayout,
type SingleTabloTabId,
useChatUnread,
} from "@xtablo/tablo-views";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
@ -22,33 +31,9 @@ import {
DialogTitle,
} from "@xtablo/ui/components/dialog";
import { Input } from "@xtablo/ui/components/input";
import {
CalendarIcon,
CircleCheckIcon,
Compass,
EllipsisVerticalIcon,
FileTextIcon,
Flame,
FolderIcon,
Gem,
Heart,
KanbanIcon,
LayoutDashboardIcon,
Leaf,
ListChecksIcon,
MapIcon,
MessageCircleIcon,
PlusIcon,
Sparkles,
Star,
Sun,
UserPlusIcon,
Waves,
XIcon,
Zap,
} from "lucide-react";
import { XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
useCancelClientInvite,
useCreateClientInvite,
@ -79,111 +64,24 @@ import {
useAllTasks,
useCreateEtape,
useCreateTask,
useDeleteTask,
useTabloEtapes,
useUpdateTask,
useUpdateTaskPositions,
} from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
import { getEtapeProgressStats } from "../utils/etapeProgress";
import {
DEFAULT_OVERVIEW_LAYOUT,
type OverviewBlockId,
type OverviewLayoutV1,
sanitizeOverviewLayout,
} from "./tablo-details/overviewLayout";
import { moveBetweenZones, moveWithinZone } from "./tablo-details/overviewReorder";
// ─── Icon helpers ─────────────────────────────────────────────────────────────
type TabSection = SingleTabloTabId;
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;
}
}
function getTabloIconColor(color: string | null | undefined): string {
switch (color) {
case "bg-yellow-500":
case "bg-cyan-500":
return "text-gray-700";
default:
return "text-white";
}
}
// ─── Status helpers ───────────────────────────────────────────────────────────
function getStatusConfig(status: string) {
switch (status) {
case "in_progress":
return {
label: "En cours",
badgeClass:
"bg-yellow-50 text-yellow-700 border border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-800",
};
case "done":
return {
label: "Terminé",
badgeClass:
"bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
};
default:
return {
label: "À faire",
badgeClass:
"bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
};
}
}
// ─── Tabs ─────────────────────────────────────────────────────────────────────
type TabSection =
| "overview"
| "board"
| "list"
| "roadmap"
| "calendar"
| "files"
| "discussion"
| "events"
| "tasks"
| "etapes";
const TABS: {
id: TabSection;
label: string;
icon: React.ElementType;
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 },
{ id: "events", label: "Événements", icon: CalendarIcon },
{ id: "roadmap", label: "Roadmap", icon: MapIcon },
const TAB_SECTIONS: TabSection[] = [
"overview",
"etapes",
"tasks",
"files",
"discussion",
"events",
"roadmap",
];
// Temporary rollback until the client portal invite flow is ready to be used again.
@ -225,6 +123,7 @@ export const TabloDetailsPage = () => {
const { mutate: cancelClientInvite, isPending: isCancellingClientInvite } =
useCancelClientInvite();
const { mutate: updateTask } = useUpdateTask();
const { mutate: deleteTask } = useDeleteTask();
const { mutate: updateTablo, mutateAsync: updateTabloAsync } = useUpdateTablo();
const { mutate: createTask } = useCreateTask();
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
@ -278,9 +177,7 @@ export const TabloDetailsPage = () => {
const sectionParam = searchParams.get("section") as TabSection | null;
const activeSection: TabSection =
sectionParam && TABS.some((t) => t.id === sectionParam && !t.disabled)
? sectionParam
: "overview";
sectionParam && TAB_SECTIONS.includes(sectionParam) ? sectionParam : "overview";
const [tablo, setTablo] = useState<UserTablo | null>(null);
@ -330,11 +227,9 @@ export const TabloDetailsPage = () => {
if (!tablo) return null;
const { label: statusLabel, badgeClass } = getStatusConfig(tablo.status);
const { label: statusLabel, badgeClass } = getSingleTabloStatusConfig(tablo.status);
const progress = getEtapeProgressStats(etapes);
const isAdmin = tablo.is_admin;
const TabloIcon = getTabloIcon(tablo.color);
const iconColor = getTabloIconColor(tablo.color);
const persistOverviewLayout = (
nextLayout: OverviewLayoutV1,
@ -431,528 +326,165 @@ export const TabloDetailsPage = () => {
};
return (
<div className={cn(activeSection === "discussion" && "flex flex-col h-[calc(100vh-75px)]")}>
{/* ── Header ──────────────────────────────────────────────────────── */}
<div className="px-4 pt-4">
<div className="flex flex-col md:flex-row items-start justify-between mb-6 border-b border-[#F2F4F7] dark:border-gray-700 pb-5 gap-5 md:gap-0">
<div className="flex items-center gap-4">
<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 ? (
<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>
</div>
<>
<SingleTabloView
tablo={tablo}
roleLabel={isAdmin ? "Admin" : "Invité"}
statusLabel={statusLabel}
statusBadgeClass={badgeClass}
progress={progress}
activeTab={activeSection}
onTabChange={(tabId) => setSearchParams({ section: tabId })}
hasUnreadDiscussion={hasUnreadDiscussion}
discussionAction={{ kind: "link", to: `/chat/${tabloId}` }}
canInviteMembers={isAdmin}
onOpenInviteDialog={() => setIsShareDialogOpen(true)}
>
{activeSection === "overview" && (
<SingleTabloOverview
roleLabel={isAdmin ? "Admin" : "Invité"}
statusLabel={statusLabel}
statusBadgeClass={badgeClass}
description="Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les onglets ci-dessus pour naviguer entre les différentes sections."
tasks={visibleOverviewTasks}
projectTaskCount={tabloTasks.length}
personalTaskCount={myTabloTasks.length}
fileNames={fileNames}
showFileMenu={true}
onOpenTasks={() => setSearchParams({ section: "tasks" })}
onOpenFiles={() => setSearchParams({ section: "files" })}
onCreateTask={() => openTaskModal()}
onToggleTaskDone={(taskId) => updateTask({ id: taskId, status: "done" })}
showAllTasks={showAllOverviewTasks}
onToggleShowAllTasks={() => setShowAllOverviewTasks((prev) => !prev)}
layout={overviewLayout}
canEditLayout={isAdmin}
isLayoutEditMode={isLayoutEditMode}
draggedBlock={draggedOverviewBlock}
onToggleLayoutEditMode={() => setIsLayoutEditMode((prev) => !prev)}
onResetLayout={handleResetOverviewLayout}
onBlockDragStart={handleOverviewBlockDragStart}
onBlockDragOver={handleOverviewBlockDragOver}
onBlockDrop={handleOverviewBlockDrop}
onBlockDragEnd={() => setDraggedOverviewBlock(null)}
/>
)}
<div className="flex items-center gap-3 w-full sm:w-auto">
<Link
to={`/chat/${tabloId}`}
className="bg-[#804EEC] hover:bg-[#6f3fd4] text-white font-medium py-2.5 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors flex-1 sm:flex-none min-h-[44px]"
>
<MessageCircleIcon className="w-5 h-5" />
Discussion
</Link>
{isAdmin && (
<button
type="button"
onClick={() => setIsShareDialogOpen(true)}
className="border border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10 font-medium py-2.5 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors flex-1 sm:flex-none min-h-[44px]"
>
<UserPlusIcon className="w-5 h-5" />
Inviter
</button>
)}
</div>
</div>
{/* ── Metadata bar ──────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-3 sm:gap-6 text-sm border-b border-[#F2F4F7] dark:border-gray-700 pb-4 mb-4">
<div className="flex items-center gap-2 sm:border-r border-[#D0D5DD] dark:border-gray-600 sm:pr-4">
<span className="text-muted-foreground">Rôle :</span>
<span className="text-foreground font-medium">{isAdmin ? "Admin" : "Invité"}</span>
</div>
<div className="flex items-center gap-2 sm:border-r border-[#D0D5DD] dark:border-gray-600 sm:pr-4">
<span className="text-muted-foreground">Créé le :</span>
<span className="text-foreground">
{new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "short",
day: "2-digit",
}).format(new Date(tablo.created_at))}
</span>
</div>
<div className="flex items-center gap-2 sm:border-r border-[#D0D5DD] dark:border-gray-600 sm:pr-4">
<span className="text-muted-foreground">Statut :</span>
<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="relative w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-blue-500/40"
style={{ width: `${progress.startedPercentage}%` }}
/>
<div
className="absolute inset-y-0 left-0 bg-green-500"
style={{ width: `${progress.donePercentage}%` }}
/>
</div>
<span className="text-foreground font-medium">{progress.donePercentage}%</span>
</div>
</div>
</div>
{/* ── Tab navigation ──────────────────────────────────────────────── */}
<div className="w-full bg-white dark:bg-background sticky top-0 z-40">
<div className="px-4 py-2">
<div className="flex items-center gap-4 sm:gap-6 mb-4 border-b border-[#F2F4F7] dark:border-gray-700 overflow-x-auto scrollbar-none -mx-4 px-4">
{TABS.map((tab) => {
const isActive = activeSection === tab.id;
return (
<button
key={tab.id}
type="button"
disabled={tab.disabled}
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 shrink-0 min-h-[44px]",
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"
)}
>
<span className="relative inline-flex">
<tab.icon className="w-4 h-4" />
{tab.id === "discussion" && hasUnreadDiscussion && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-red-500" />
)}
</span>
<span>{tab.label}</span>
{tab.disabled && (
<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
</span>
)}
</button>
);
})}
</div>
</div>
</div>
{/* ── Tab content ─────────────────────────────────────────────────── */}
<div
className={cn(
"px-4 sm:px-6 pt-6 pb-8",
activeSection === "discussion" && "flex flex-col flex-1 min-h-0 !px-0 !pt-0 !pb-0"
)}
>
{activeSection === "overview" &&
(() => {
const overviewBlocks: Record<OverviewBlockId, React.ReactNode> = {
description: (
<div className="bg-white dark:bg-card rounded-xl border border-border p-6 sm:p-8 shadow-sm">
<h2 className="text-xl sm:text-2xl font-bold text-foreground mb-4">
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.
</p>
</div>
),
myTasks: (
<div className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700 gap-3">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-gray-100">
Mes tâches
</h2>
<button
type="button"
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" />
<span className="text-sm">Ajouter</span>
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{myTabloTasks.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">
Aucune tâche
</div>
) : (
visibleOverviewTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
onClick={() => setSearchParams({ section: "tasks" })}
>
<button
type="button"
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
if (task.status !== "done") {
updateTask({ id: task.id, status: "done" });
}
}}
aria-label={
task.status === "done"
? "Tâche terminée"
: "Marquer la tâche comme terminée"
}
>
{task.status === "done" ? (
<CircleCheckIcon className="w-5 h-5 text-green-500" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600" />
)}
</button>
<p
className={cn(
"text-sm font-medium truncate",
task.status === "done"
? "line-through text-gray-400"
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
</p>
</div>
))
)}
{myTabloTasks.length > 5 && (
<button
type="button"
onClick={() => setShowAllOverviewTasks((prev) => !prev)}
className="w-full p-3 text-sm text-[#804EEC] hover:underline text-center"
>
{showAllOverviewTasks
? "Voir moins"
: `Voir les ${myTabloTasks.length - 5} tâches restantes`}
</button>
)}
</div>
</div>
),
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>
<button
type="button"
onClick={() => setSearchParams({ section: "files" })}
className="text-sm text-[#804EEC] hover:underline"
>
Voir tout
</button>
</div>
<div className="space-y-3">
{fileNames.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun fichier</p>
) : (
fileNames.slice(0, 5).map((fileName) => (
<div
key={fileName}
className="flex items-start gap-3 p-3 hover:bg-accent dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/30 rounded-lg flex items-center justify-center shrink-0">
<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>
</div>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 shrink-0"
>
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
),
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>
<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>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Fichiers</dt>
<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)}
>
{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>
</div>
</dl>
</div>
),
};
return (
<>
{isAdmin && (
<div className="flex flex-wrap items-center justify-end gap-2 mb-4">
{isLayoutEditMode && (
<button
type="button"
onClick={handleResetOverviewLayout}
className="border border-gray-300 dark:border-gray-700 text-foreground hover:bg-gray-100 dark:hover:bg-gray-800 font-medium py-2 px-4 rounded-lg transition-colors"
>
Réinitialiser
</button>
)}
<button
type="button"
onClick={() => setIsLayoutEditMode((prev) => !prev)}
className={cn(
"font-medium py-2 px-4 rounded-lg transition-colors",
isLayoutEditMode
? "bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900"
: "border border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10"
)}
>
{isLayoutEditMode ? "Terminer la mise en page" : "Modifier la mise en page"}
</button>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div
className="lg:col-span-2 space-y-6"
onDragOver={handleOverviewBlockDragOver}
onDrop={(event) => {
event.preventDefault();
handleOverviewBlockDrop("left", overviewLayout.leftZone.length);
}}
>
{overviewLayout.leftZone.map((blockId, index) => (
<div
key={`${blockId}-${index}`}
draggable={isLayoutEditMode}
onDragStart={(event) => handleOverviewBlockDragStart(event, "left", index)}
onDragOver={handleOverviewBlockDragOver}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
handleOverviewBlockDrop("left", index);
}}
onDragEnd={() => setDraggedOverviewBlock(null)}
className={cn(
isLayoutEditMode && "cursor-move",
isLayoutEditMode &&
draggedOverviewBlock?.zone === "left" &&
draggedOverviewBlock.index === index &&
"opacity-60"
)}
>
{isLayoutEditMode && (
<div className="flex items-center justify-end mb-2 text-xs text-muted-foreground">
<EllipsisVerticalIcon className="w-4 h-4 mr-1" />
Glisser pour réorganiser
</div>
)}
{overviewBlocks[blockId]}
</div>
))}
{isLayoutEditMode && overviewLayout.leftZone.length === 0 && (
<div className="border border-dashed border-gray-300 dark:border-gray-700 rounded-xl p-4 text-sm text-muted-foreground text-center">
Déposez un bloc ici
</div>
)}
</div>
<div
className="space-y-6"
onDragOver={handleOverviewBlockDragOver}
onDrop={(event) => {
event.preventDefault();
handleOverviewBlockDrop("right", overviewLayout.rightZone.length);
}}
>
{overviewLayout.rightZone.map((blockId, index) => (
<div
key={`${blockId}-${index}`}
draggable={isLayoutEditMode}
onDragStart={(event) => handleOverviewBlockDragStart(event, "right", index)}
onDragOver={handleOverviewBlockDragOver}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
handleOverviewBlockDrop("right", index);
}}
onDragEnd={() => setDraggedOverviewBlock(null)}
className={cn(
isLayoutEditMode && "cursor-move",
isLayoutEditMode &&
draggedOverviewBlock?.zone === "right" &&
draggedOverviewBlock.index === index &&
"opacity-60"
)}
>
{isLayoutEditMode && (
<div className="flex items-center justify-end mb-2 text-xs text-muted-foreground">
<EllipsisVerticalIcon className="w-4 h-4 mr-1" />
Glisser pour réorganiser
</div>
)}
{overviewBlocks[blockId]}
</div>
))}
{isLayoutEditMode && overviewLayout.rightZone.length === 0 && (
<div className="border border-dashed border-gray-300 dark:border-gray-700 rounded-xl p-4 text-sm text-muted-foreground text-center">
Déposez un bloc ici
</div>
)}
</div>
</div>
</>
);
})()}
{activeSection === "tasks" && (
<TabloTasksSection
tablo={tablo}
isAdmin={isAdmin}
tasks={tabloTasks}
members={members}
etapes={etapes}
currentUser={currentUser}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onCreateTask={(task) => createTask(task)}
onUpdateTask={(task) => updateTask(task)}
onUpdateTaskPositions={(updates) => updateTaskPositions(updates)}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "files" && (
<TabloFilesSection
{activeSection === "tasks" && (
<TabloTasksSection
tablo={tablo}
isAdmin={isAdmin}
tasks={tabloTasks}
members={members}
etapes={etapes}
currentUser={currentUser}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onCreateTask={(task) => createTask(task)}
onUpdateTask={(task) => updateTask(task)}
onDeleteTask={(taskId) => deleteTask(taskId)}
onUpdateTaskPositions={(updates) => updateTaskPositions(updates)}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "files" && (
<TabloFilesSection
tablo={tablo}
isAdmin={isAdmin}
currentUserId={currentUser.id}
fileNames={fileNames}
filesLoading={false}
filesError={null}
folders={foldersData?.folders ?? []}
foldersLoading={foldersLoading}
foldersError={foldersError as Error | null}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
isCreatingFolder={isCreatingFolder}
isUpdatingFolder={isUpdatingFolder}
onCreateFile={(params) => uploadFile(params).then(() => undefined)}
onDeleteFile={(params) => deleteFile(params).then(() => undefined)}
onDownloadFile={(params) => downloadFile(params).then(() => undefined)}
onCreateFolder={(params) => createFolder(params).then(() => undefined)}
onUpdateFolder={(params) => updateFolder(params).then(() => undefined)}
onDeleteFolder={(params) => deleteFolder(params).then(() => undefined)}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "discussion" && (
<div className="flex-1 min-h-0">
<TabloDiscussionSection
tablo={tablo}
isAdmin={isAdmin}
currentUserId={currentUser.id}
fileNames={(filesData?.fileNames ?? []).filter((f) => !f.startsWith("."))}
filesLoading={false}
filesError={null}
folders={foldersData?.folders ?? []}
foldersLoading={foldersLoading}
foldersError={foldersError as Error | null}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
isCreatingFolder={isCreatingFolder}
isUpdatingFolder={isUpdatingFolder}
onCreateFile={(params) => uploadFile(params).then(() => undefined)}
onDeleteFile={(params) => deleteFile(params).then(() => undefined)}
onDownloadFile={(params) => downloadFile(params).then(() => undefined)}
onCreateFolder={(params) => createFolder(params).then(() => undefined)}
onUpdateFolder={(params) => updateFolder(params).then(() => undefined)}
onDeleteFolder={(params) => deleteFolder(params).then(() => undefined)}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "discussion" && (
<div className="flex-1 min-h-0">
<TabloDiscussionSection
tablo={tablo}
isAdmin={isAdmin}
currentUserId={currentUser.id}
members={members}
/>
</div>
)}
{activeSection === "events" && (
<TabloEventsSection
tablo={tablo}
isAdmin={isAdmin}
events={events ?? []}
isLoading={eventsLoading}
error={eventsError as Error | null}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onCreateEvent={() => undefined}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
</div>
)}
{activeSection === "events" && (
<TabloEventsSection
tablo={tablo}
isAdmin={isAdmin}
events={events ?? []}
isLoading={eventsLoading}
error={eventsError as Error | null}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onCreateEvent={() => undefined}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "etapes" && (
<EtapesSection
etapes={etapes}
tabloTasks={tabloTasks}
tabloId={tabloId ?? ""}
isAdmin={isAdmin}
onCreateTask={(task) =>
createTask({
...task,
status: task.status as "todo" | "in_progress" | "in_review" | "done",
})
}
onCreateEtape={(params) => createEtape(params).then(() => undefined)}
isCreatingEtape={isCreatingEtape}
/>
)}
{activeSection === "etapes" && (
<EtapesSection
etapes={etapes}
tabloTasks={tabloTasks}
tabloId={tabloId ?? ""}
isAdmin={isAdmin}
onCreateTask={(task) =>
createTask({
...task,
status: task.status as "todo" | "in_progress" | "in_review" | "done",
})
}
onCreateEtape={(params) => createEtape(params).then(() => undefined)}
isCreatingEtape={isCreatingEtape}
/>
)}
{activeSection === "roadmap" && (
<RoadmapSection
tabloTasks={tabloTasks}
onDateClick={openTaskModal}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
/>
)}
</div>
{activeSection === "roadmap" && (
<RoadmapSection
tabloTasks={tabloTasks}
onDateClick={openTaskModal}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
/>
)}
</SingleTabloView>
{/* Task Create Modal */}
{tabloId && (
@ -962,6 +494,9 @@ export const TabloDetailsPage = () => {
onClose={closeTaskModal}
initialStatus="todo"
initialDueDate={taskModalInitialDueDate}
onCreateTask={(task) => createTask(task)}
onUpdateTask={(task) => updateTask(task)}
onDeleteTask={(taskId) => deleteTask(taskId)}
/>
)}
@ -1217,6 +752,6 @@ export const TabloDetailsPage = () => {
</div>
</DialogContent>
</Dialog>
</div>
</>
);
};

View file

@ -0,0 +1,249 @@
# Clients Exact Tablo Parity
**Date**: 2026-04-16
**Status**: Draft
**Supersedes**: `docs/superpowers/specs/2026-04-15-client-portal-tablo-parity-design.md`
## Overview
`clients.xtablo.com` is intended to be a portal for the clients of our clients. For the single-tablo experience, it must render the exact same UI surface as `app.xtablo.com` on `apps/main/src/pages/tablo-details.tsx`.
The current codebase does not guarantee that outcome because `apps/main` and `apps/clients` still compose the page separately. Shared sections and some shared CSS exist, but the full single-tablo route is not owned by one shared render surface.
The target is stricter than "close parity":
- same page shell
- same header structure
- same metadata row
- same tab bar
- same overview layout
- same section framing
- same responsive behavior
- same route-level CSS source
- same component tree for the single-tablo view
The only intended differences are:
- `apps/main` keeps URL-backed section state
- `apps/clients` keeps in-memory section state
- `apps/main` exposes full admin and mutation capabilities
- `apps/clients` exposes a restricted client-safe capability set
## Problem Statement
`clients.xtablo.com` is not at feature parity today for structural reasons, not because of one isolated CSS bug.
### Current causes of drift
1. `apps/clients/src/pages/ClientTabloPage.tsx` reconstructs a client-specific page instead of rendering the same single-tablo surface as `apps/main`.
2. `apps/clients/src/components/ClientLayout.tsx` owns a separate app shell, so spacing, header behavior, and responsive layout can drift from the main app.
3. Shared CSS exists only partially. Route-level tokens and chat/page styling have been extracted in places, but the full single-tablo view is still not governed by one shared route stylesheet plus one shared render tree.
4. Permissions are mixed into page composition instead of being expressed as a clean capability model. That forces `clients` to fork render logic instead of rendering the same surface with different behavior gates.
The result is predictable: any visual or structural change to the main tablo route risks being manually reimplemented in `clients`, and parity becomes a maintenance task instead of an invariant.
## Goals
- Make the single-tablo UI in `clients.xtablo.com` visually identical to `apps/main/src/pages/tablo-details.tsx`
- Enforce parity through one shared single-tablo render surface
- Share the route-level CSS and responsive behavior between both apps
- Keep `apps/clients` on in-memory tab state
- Keep `discussion` writable in client mode
- Keep the rest of the client experience read-only or admin-hidden as approved
## Non-Goals
- Rebuilding the client portal as a separate visual concept
- Introducing query-param section routing into `apps/clients`
- Enabling client-side admin actions
- Refactoring unrelated routes outside the single-tablo experience
- Deleting the newer client-specific invite system from the codebase
## Hard Requirement
The UI must be the exact same.
That rules out maintaining two parallel page compositions for the single-tablo route. "Shared components plus duplicated page assembly" is not sufficient because parity will drift again. The single-tablo view must be rendered from one shared composition surface consumed by both apps.
## Chosen Approach
Create a shared single-tablo route surface in `packages/tablo-views` and make both apps consume it.
This shared surface owns the exact structure, responsive layout, CSS import, tab order, overview composition, and section framing for the route.
Each app becomes a thin adapter:
- `apps/main` passes full-capability handlers and URL-backed section state
- `apps/clients` passes restricted capabilities and in-memory section state
This is a consolidation, not a styling pass.
## Architecture
### Shared package ownership
`packages/tablo-views` should own the single-tablo route surface for both apps, including:
- header layout
- title and icon/image block
- metadata row
- sticky tab navigation
- overview block composition
- section container layout
- discussion full-height layout behavior
- route-specific CSS for this surface
This shared surface should render the same DOM structure and use the same styling hooks regardless of app.
### App adapter ownership
`apps/main` should own:
- `?section=` query-param state
- full mutation handlers
- admin-only actions
- share and invite workflows
- main-only routing integrations
`apps/clients` should own:
- local in-memory tab state
- client-safe data loading
- capability restrictions
- client-safe app shell concerns outside the single-tablo surface
### Key principle
The apps must differ by inputs, not by page composition.
## Capability Model
The shared single-tablo surface should branch on capabilities rather than on app identity.
Suggested capability contract:
- `canCreateTasks`
- `canEditTasks`
- `canEditEvents`
- `canManageFiles`
- `canManageMembers`
- `canInviteMembers`
- `canEditLayout`
- `canWriteDiscussion`
### Approved client boundary
For `clients.xtablo.com`:
- `discussion`: writable
- file read/download behavior: allowed where already supported
- task edits: disabled
- event edits: disabled
- layout edits: hidden
- member management: hidden or read-only
- invite/share management: hidden
This preserves the same page structure while changing behavior safely.
## CSS And Responsiveness
Exact UI parity requires a single route-level CSS source for the single-tablo experience.
### Requirements
- the shared single-tablo surface imports one shared route stylesheet from `packages/tablo-views`
- route-level tokens for navbar, metadata, sticky tabs, and discussion/chat visuals come from that shared stylesheet
- responsive breakpoints and overflow behavior are not redefined independently in `apps/main` and `apps/clients`
### Constraint
`apps/clients` must not carry a competing route-specific single-tablo style layer that can override the shared surface in divergent ways.
App-local CSS can remain for app-wide concerns, but the single-tablo route must have one styling owner.
## State Model
The visual surface is shared, but tab state differs by app.
### Main app
- active section comes from `?section=...`
- existing route behavior remains intact
### Clients app
- active section is stored in local React state
- no query-param synchronization is required
This is acceptable because the user explicitly approved in-memory state for clients.
## Migration Plan
### Phase 1: Consolidate the surface
- identify all remaining structure in `apps/main/src/pages/tablo-details.tsx` that is still page-owned instead of shared
- move that structure into a shared single-tablo route surface in `packages/tablo-views`
- make `apps/main` consume the shared surface first, preserving current behavior
### Phase 2: Convert the clients app into an adapter
- remove duplicated page composition from `apps/clients/src/pages/ClientTabloPage.tsx`
- replace it with a thin adapter that passes client-safe data and capability flags into the shared surface
- keep local in-memory tab state in the adapter only
### Phase 3: Normalize the shell
- ensure the surrounding shell and route-level CSS used by the single-tablo surface are shared consistently
- keep `ClientLayout` responsible only for client-portal app concerns, not for redefining the single-tablo view
### Phase 4: Lock parity with tests
- add tests that assert `main` and `clients` render the same shared single-tablo structure
- add client-mode tests for capability restrictions
- add responsive regression coverage where practical
## Testing Strategy
Verification must prove parity, not just correctness.
### Automated
- shared render tests for the single-tablo route surface in `packages/tablo-views`
- adapter tests proving `apps/main` and `apps/clients` pass different state/capabilities into the same surface
- regression tests that the client app hides or disables admin-only actions while keeping discussion writable
- targeted CSS contract tests ensuring both apps import the same shared route stylesheet
### Manual
Side-by-side comparison between:
- `apps/main` `/tablos/:tabloId`
- `apps/clients` `/tablo/:tabloId`
At minimum verify:
- desktop layout
- mobile layout
- sticky tabs
- overview cards
- discussion layout
- header wrapping and spacing
- empty states
## Risks
- `apps/main/src/pages/tablo-details.tsx` may still contain too much mixed business logic and render logic, making extraction noisy
- some shared sections may still assume main-app permissions implicitly
- partial CSS ownership may continue to cause drift if not fully normalized
- client-safe data access may reveal places where the UI currently assumes admin or member-level data is always present
## Success Criteria
This work is successful when:
- `clients.xtablo.com` renders the same single-tablo UI as `app.xtablo.com`
- future UI changes to the single-tablo route normally require edits in one shared place
- `apps/clients` remains in-memory for tab state
- client restrictions match the approved boundary
- discussion remains writable in client mode
- parity is enforced structurally, not maintained manually

View file

@ -108,27 +108,29 @@ export function EtapesSection({
return (
<div className="space-y-4">
{isAdmin && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<form
className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
void handleAddEtape();
}}
>
<Input
value={newEtapeTitle}
onChange={(event) => setNewEtapeTitle(event.target.value)}
placeholder="Nom de la nouvelle étape..."
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleAddEtape();
}
}}
required
className="h-11 sm:h-9 sm:w-80"
/>
<Button
onClick={() => void handleAddEtape()}
disabled={isCreatingEtape || !newEtapeTitle.trim()}
type="submit"
disabled={isCreatingEtape}
className="min-h-[44px] sm:min-h-0"
>
<PlusIcon className="w-4 h-4" />
Ajouter une étape
</Button>
</div>
</form>
)}
{etapes.length === 0 ? (

View file

@ -38,6 +38,7 @@ interface TabloTasksSectionProps {
isCancellingInvite?: boolean;
onCreateTask?: (task: KanbanTaskInsert) => void;
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
onDeleteTask?: (taskId: string) => void;
onUpdateTaskPositions?: (updates: Array<{ id: string; position: number; status: TaskStatus }>) => void;
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
@ -56,6 +57,7 @@ export const TabloTasksSection = ({
isCancellingInvite,
onCreateTask,
onUpdateTask,
onDeleteTask,
onUpdateTaskPositions,
onUpdateTablo,
onInviteUser,
@ -278,6 +280,7 @@ export const TabloTasksSection = ({
etapes={etapes}
onCreateTask={onCreateTask}
onUpdateTask={onUpdateTask}
onDeleteTask={onDeleteTask}
/>
</div>
);

View file

@ -40,6 +40,8 @@ interface TaskModalProps {
onCreateTask?: (task: KanbanTaskInsert) => void;
/** Called when updating an existing task. */
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
/** Called when deleting an existing task. */
onDeleteTask?: (taskId: string) => void;
}
export const TaskModal = ({
@ -56,6 +58,7 @@ export const TaskModal = ({
initialDueDate,
onCreateTask,
onUpdateTask,
onDeleteTask,
}: TaskModalProps) => {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
@ -137,6 +140,12 @@ export const TaskModal = ({
onClose();
};
const handleDelete = () => {
if (!taskId || !task) return;
onDeleteTask?.(task.id);
onClose();
};
if (!isOpen) return null;
return (
@ -252,6 +261,16 @@ export const TaskModal = ({
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
{taskId && task && (
<Button
type="button"
variant="destructive"
className="mr-auto"
onClick={handleDelete}
>
Supprimer la tâche
</Button>
)}
<Button type="button" variant="ghost" onClick={onClose}>
Annuler
</Button>