feat: add shared single tablo view

This commit is contained in:
Arthur Belleville 2026-04-16 11:34:49 +02:00
parent fc82ea1949
commit 37ab73bced
No known key found for this signature in database
7 changed files with 585 additions and 1 deletions

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/components/clientlayout.tsx","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"}

View file

@ -7,6 +7,16 @@ export { RoadmapSection } from "./RoadmapSection";
export { TabloHeaderActions } from "./TabloHeaderActions";
export { ChatMessages } from "./ChatMessages";
export { TabloDetailsShell } from "./TabloDetailsShell";
export { SingleTabloOverview } from "./single-tablo/SingleTabloOverview";
export { SingleTabloView } from "./single-tablo/SingleTabloView";
export {
DEFAULT_OVERVIEW_LAYOUT,
sanitizeOverviewLayout,
} from "./single-tablo/overviewLayout";
export type { OverviewBlockId, OverviewLayoutV1 } from "./single-tablo/overviewLayout";
export { moveBetweenZones, moveWithinZone } from "./single-tablo/overviewReorder";
export { getSingleTabloStatusConfig } from "./single-tablo/shared";
export type { SingleTabloTabId } from "./single-tablo/shared";
// Sub-components
export { GanttChart } from "./components/gantt/GanttChart";

View file

@ -0,0 +1,262 @@
import { cn } from "@xtablo/shared";
import type { KanbanTask } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import { FileIcon, GripVerticalIcon, LayoutPanelTopIcon, ListChecksIcon, RotateCcwIcon } from "lucide-react";
import type { OverviewBlockId, OverviewLayoutV1 } from "./overviewLayout";
interface DraggedBlock {
zone: "left" | "right";
index: number;
}
interface SingleTabloOverviewProps {
roleLabel?: string;
statusLabel?: string;
statusBadgeClass?: string;
description: string;
tasks: KanbanTask[];
projectTaskCount: number;
personalTaskCount: number;
fileNames: string[];
showFileMenu?: boolean;
onOpenTasks: () => void;
onOpenFiles: () => void;
onCreateTask: () => void;
onToggleTaskDone: (taskId: string) => void;
showAllTasks: boolean;
onToggleShowAllTasks: () => void;
layout: OverviewLayoutV1;
canEditLayout: boolean;
isLayoutEditMode: boolean;
draggedBlock: DraggedBlock | null;
onToggleLayoutEditMode: () => void;
onResetLayout: () => void;
onBlockDragStart: (
event: React.DragEvent<HTMLDivElement>,
zone: "left" | "right",
index: number
) => void;
onBlockDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
onBlockDrop: (zone: "left" | "right", index: number) => void;
onBlockDragEnd: () => void;
}
function OverviewCard({
title,
actions,
children,
draggable = false,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragged = false,
}: {
title: string;
actions?: React.ReactNode;
children: React.ReactNode;
draggable?: boolean;
onDragStart?: (event: React.DragEvent<HTMLDivElement>) => void;
onDragOver?: (event: React.DragEvent<HTMLDivElement>) => void;
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
onDragEnd?: () => void;
isDragged?: boolean;
}) {
return (
<div
draggable={draggable}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={cn(
"bg-white dark:bg-card rounded-xl border border-border p-6 shadow-sm",
draggable && "cursor-grab active:cursor-grabbing",
isDragged && "opacity-60"
)}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
{draggable && <GripVerticalIcon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-xl sm:text-2xl font-bold text-foreground">{title}</h2>
</div>
{actions}
</div>
{children}
</div>
);
}
export function SingleTabloOverview({
description,
tasks,
projectTaskCount,
personalTaskCount,
fileNames,
onOpenTasks,
onOpenFiles,
onCreateTask,
onToggleTaskDone,
showAllTasks,
onToggleShowAllTasks,
layout,
canEditLayout,
isLayoutEditMode,
draggedBlock,
onToggleLayoutEditMode,
onResetLayout,
onBlockDragStart,
onBlockDragOver,
onBlockDrop,
onBlockDragEnd,
}: SingleTabloOverviewProps) {
const renderBlockContent = (blockId: OverviewBlockId) => {
switch (blockId) {
case "description":
return {
title: "Description du projet",
children: (
<p className="text-muted-foreground leading-relaxed text-sm sm:text-base">
{description}
</p>
),
};
case "myTasks":
return {
title: "Mes tâches",
actions: (
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onOpenTasks}>
Voir tout
</Button>
<Button type="button" size="sm" onClick={onCreateTask}>
Ajouter
</Button>
</div>
),
children: (
<div className="space-y-3">
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune tâche.</p>
) : (
tasks.map((task) => (
<button
key={task.id}
type="button"
onClick={() => onToggleTaskDone(task.id)}
className="flex w-full items-center gap-3 rounded-lg border border-border px-3 py-2 text-left"
>
<span
className={cn(
"h-4 w-4 rounded-full border border-border",
task.status === "done" && "bg-green-500 border-green-500"
)}
/>
<span className="truncate text-sm font-medium text-foreground">
{task.title}
</span>
</button>
))
)}
{personalTaskCount > 5 && (
<Button type="button" variant="link" className="px-0" onClick={onToggleShowAllTasks}>
{showAllTasks ? "Afficher moins" : "Afficher plus"}
</Button>
)}
</div>
),
};
case "files":
return {
title: "Fichiers",
actions: (
<Button type="button" variant="ghost" size="sm" onClick={onOpenFiles}>
Ouvrir
</Button>
),
children: (
<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-center gap-2 text-sm text-foreground">
<FileIcon className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{fileName}</span>
</div>
))
)}
</div>
),
};
case "info":
return {
title: "Informations",
children: (
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<ListChecksIcon className="h-4 w-4" />
<span>{projectTaskCount} tâches sur le projet</span>
</div>
<div className="flex items-center gap-2">
<LayoutPanelTopIcon className="h-4 w-4" />
<span>{personalTaskCount} tâches pour vous</span>
</div>
</div>
),
};
}
};
const renderZone = (zone: "left" | "right", blocks: OverviewBlockId[]) => (
<div className="space-y-6">
{blocks.map((blockId, index) => {
const block = renderBlockContent(blockId);
return (
<div
key={blockId}
onDragOver={onBlockDragOver}
onDrop={() => onBlockDrop(zone, index)}
>
<OverviewCard
title={block.title}
actions={block.actions}
draggable={isLayoutEditMode}
onDragStart={(event) => onBlockDragStart(event, zone, index)}
onDragOver={onBlockDragOver}
onDrop={() => onBlockDrop(zone, index)}
onDragEnd={onBlockDragEnd}
isDragged={draggedBlock?.zone === zone && draggedBlock.index === index}
>
{block.children}
</OverviewCard>
</div>
);
})}
</div>
);
return (
<div className="space-y-6">
{canEditLayout && (
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant={isLayoutEditMode ? "secondary" : "outline"} onClick={onToggleLayoutEditMode}>
Modifier la mise en page
</Button>
{isLayoutEditMode && (
<Button type="button" variant="ghost" onClick={onResetLayout}>
<RotateCcwIcon className="h-4 w-4" />
Réinitialiser
</Button>
)}
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">{renderZone("left", layout.leftZone)}</div>
<div>{renderZone("right", layout.rightZone)}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,169 @@
import { cn } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import {
CalendarIcon,
FolderIcon,
KanbanIcon,
LayoutDashboardIcon,
ListChecksIcon,
MapIcon,
MessageCircleIcon,
} from "lucide-react";
import { Link } from "react-router-dom";
import {
TabloDetailsShell,
type TabloDetailsShellMetadataItem,
type TabloDetailsShellTab,
} from "../TabloDetailsShell";
import type { SingleTabloTabId } from "./shared";
const TABS: TabloDetailsShellTab[] = [
{ 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 },
];
type DiscussionAction =
| {
kind: "link";
to: string;
}
| {
kind: "button";
onClick: () => void;
};
interface SingleTabloViewProps {
tablo: Pick<UserTablo, "name" | "created_at" | "status">;
roleLabel: string;
statusLabel: string;
statusBadgeClass: string;
progress: {
startedPercentage: number;
donePercentage: number;
};
activeTab: SingleTabloTabId;
onTabChange: (tabId: SingleTabloTabId) => void;
hasUnreadDiscussion?: boolean;
discussionAction?: DiscussionAction;
canInviteMembers?: boolean;
onOpenInviteDialog?: () => void;
children: React.ReactNode;
}
export function SingleTabloView({
tablo,
roleLabel,
statusLabel,
statusBadgeClass,
progress,
activeTab,
onTabChange,
hasUnreadDiscussion = false,
discussionAction,
canInviteMembers = false,
onOpenInviteDialog,
children,
}: SingleTabloViewProps) {
const metadata: TabloDetailsShellMetadataItem[] = [
{
key: "role",
label: "Rôle",
value: <span className="text-foreground font-medium">{roleLabel}</span>,
},
{
key: "created-at",
label: "Créé le",
value: (
<span className="text-foreground">
{new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "short",
day: "2-digit",
}).format(new Date(tablo.created_at))}
</span>
),
},
{
key: "status",
label: "Statut",
value: (
<span className={cn("px-3 py-1 rounded-full text-xs font-medium", statusBadgeClass)}>
{statusLabel}
</span>
),
},
{
key: "progress",
label: "Progression",
value: (
<>
<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>
</>
),
},
];
const tabs = TABS.map((tab) =>
tab.id === "discussion" ? { ...tab, hasUnread: hasUnreadDiscussion } : tab
);
const discussionButton =
discussionAction?.kind === "link" ? (
<Button asChild>
<Link to={discussionAction.to}>
<MessageCircleIcon className="w-4 h-4" />
Discussion
</Link>
</Button>
) : discussionAction?.kind === "button" ? (
<Button type="button" onClick={discussionAction.onClick}>
<MessageCircleIcon className="w-4 h-4" />
Discussion
</Button>
) : null;
const inviteButton =
canInviteMembers && onOpenInviteDialog ? (
<Button type="button" variant="outline" onClick={onOpenInviteDialog}>
Inviter
</Button>
) : null;
const headerActions =
discussionButton || inviteButton ? (
<>
{discussionButton}
{inviteButton}
</>
) : undefined;
return (
<TabloDetailsShell
tablo={tablo}
metadata={metadata}
tabs={tabs}
activeTab={activeTab}
onTabChange={(tabId) => onTabChange(tabId as SingleTabloTabId)}
isDiscussionView={activeTab === "discussion"}
headerActions={headerActions}
>
{children}
</TabloDetailsShell>
);
}

View file

@ -0,0 +1,66 @@
type OverviewLayoutVersion = 1;
export type OverviewBlockId = "description" | "myTasks" | "files" | "info";
export type OverviewLayoutV1 = {
version: OverviewLayoutVersion;
leftZone: OverviewBlockId[];
rightZone: OverviewBlockId[];
updatedAt?: string;
updatedBy?: string;
};
const DEFAULT_LEFT_ZONE: OverviewBlockId[] = ["description", "myTasks"];
const DEFAULT_RIGHT_ZONE: OverviewBlockId[] = ["files", "info"];
const ALL_BLOCK_IDS: OverviewBlockId[] = ["description", "myTasks", "files", "info"];
export const DEFAULT_OVERVIEW_LAYOUT: OverviewLayoutV1 = {
version: 1,
leftZone: DEFAULT_LEFT_ZONE,
rightZone: DEFAULT_RIGHT_ZONE,
};
function unique<T>(items: T[]): T[] {
return [...new Set(items)];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parseZone(value: unknown): OverviewBlockId[] {
if (!Array.isArray(value)) return [];
const valid = value.filter(
(item): item is OverviewBlockId =>
item === "description" || item === "myTasks" || item === "files" || item === "info"
);
return unique(valid);
}
export function sanitizeOverviewLayout(input: unknown): OverviewLayoutV1 {
if (!isObject(input)) {
return DEFAULT_OVERVIEW_LAYOUT;
}
const leftZoneInput = parseZone(input.leftZone);
const rightZoneInput = parseZone(input.rightZone);
const leftZoneSize = leftZoneInput.length;
const allBlocks = unique([...leftZoneInput, ...rightZoneInput]);
for (const blockId of ALL_BLOCK_IDS) {
if (!allBlocks.includes(blockId)) {
allBlocks.push(blockId);
}
}
const safeLeftZoneSize = Math.min(leftZoneSize, allBlocks.length);
const leftZone = allBlocks.slice(0, safeLeftZoneSize);
const rightZone = allBlocks.slice(safeLeftZoneSize);
return {
version: 1,
leftZone,
rightZone,
};
}

View file

@ -0,0 +1,46 @@
export function moveWithinZone<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex > items.length) {
return items;
}
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
const insertionIndex = Math.min(toIndex, next.length);
next.splice(insertionIndex, 0, moved);
if (next.every((item, index) => item === items[index])) {
return items;
}
return next;
}
export function moveBetweenZones<T>(
sourceItems: T[],
targetItems: T[],
sourceIndex: number,
targetIndex: number
): {
sourceItems: T[];
targetItems: T[];
} {
if (
sourceIndex < 0 ||
sourceIndex >= sourceItems.length ||
targetIndex < 0 ||
targetIndex > targetItems.length
) {
return { sourceItems, targetItems };
}
const nextSourceItems = [...sourceItems];
const [moved] = nextSourceItems.splice(sourceIndex, 1);
const nextTargetItems = [...targetItems];
const insertionIndex = Math.min(targetIndex, nextTargetItems.length);
nextTargetItems.splice(insertionIndex, 0, moved);
return {
sourceItems: nextSourceItems,
targetItems: nextTargetItems,
};
}

View file

@ -0,0 +1,31 @@
export type SingleTabloTabId =
| "overview"
| "etapes"
| "tasks"
| "files"
| "discussion"
| "events"
| "roadmap";
export function getSingleTabloStatusConfig(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",
};
}
}