Merge pull request #77 from artslidd/parity-with-client
Parity with client
This commit is contained in:
commit
b75e5e658f
6 changed files with 516 additions and 655 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue