diff --git a/apps/clients/tsconfig.tsbuildinfo b/apps/clients/tsconfig.tsbuildinfo index a7db947..1df4210 100644 --- a/apps/clients/tsconfig.tsbuildinfo +++ b/apps/clients/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/packages/tablo-views/src/index.ts b/packages/tablo-views/src/index.ts index b67c572..ab47c35 100644 --- a/packages/tablo-views/src/index.ts +++ b/packages/tablo-views/src/index.ts @@ -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"; diff --git a/packages/tablo-views/src/single-tablo/SingleTabloOverview.tsx b/packages/tablo-views/src/single-tablo/SingleTabloOverview.tsx new file mode 100644 index 0000000..012828d --- /dev/null +++ b/packages/tablo-views/src/single-tablo/SingleTabloOverview.tsx @@ -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, + zone: "left" | "right", + index: number + ) => void; + onBlockDragOver: (event: React.DragEvent) => 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) => void; + onDragOver?: (event: React.DragEvent) => void; + onDrop?: (event: React.DragEvent) => void; + onDragEnd?: () => void; + isDragged?: boolean; +}) { + return ( +
+
+
+ {draggable && } +

{title}

+
+ {actions} +
+ {children} +
+ ); +} + +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: ( +

+ {description} +

+ ), + }; + case "myTasks": + return { + title: "Mes tâches", + actions: ( +
+ + +
+ ), + children: ( +
+ {tasks.length === 0 ? ( +

Aucune tâche.

+ ) : ( + tasks.map((task) => ( + + )) + )} + {personalTaskCount > 5 && ( + + )} +
+ ), + }; + case "files": + return { + title: "Fichiers", + actions: ( + + ), + children: ( +
+ {fileNames.length === 0 ? ( +

Aucun fichier.

+ ) : ( + fileNames.slice(0, 5).map((fileName) => ( +
+ + {fileName} +
+ )) + )} +
+ ), + }; + case "info": + return { + title: "Informations", + children: ( +
+
+ + {projectTaskCount} tâches sur le projet +
+
+ + {personalTaskCount} tâches pour vous +
+
+ ), + }; + } + }; + + const renderZone = (zone: "left" | "right", blocks: OverviewBlockId[]) => ( +
+ {blocks.map((blockId, index) => { + const block = renderBlockContent(blockId); + + return ( +
onBlockDrop(zone, index)} + > + onBlockDragStart(event, zone, index)} + onDragOver={onBlockDragOver} + onDrop={() => onBlockDrop(zone, index)} + onDragEnd={onBlockDragEnd} + isDragged={draggedBlock?.zone === zone && draggedBlock.index === index} + > + {block.children} + +
+ ); + })} +
+ ); + + return ( +
+ {canEditLayout && ( +
+ + {isLayoutEditMode && ( + + )} +
+ )} + +
+
{renderZone("left", layout.leftZone)}
+
{renderZone("right", layout.rightZone)}
+
+
+ ); +} diff --git a/packages/tablo-views/src/single-tablo/SingleTabloView.tsx b/packages/tablo-views/src/single-tablo/SingleTabloView.tsx new file mode 100644 index 0000000..4e3b44e --- /dev/null +++ b/packages/tablo-views/src/single-tablo/SingleTabloView.tsx @@ -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; + 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: {roleLabel}, + }, + { + key: "created-at", + label: "Créé le", + value: ( + + {new Intl.DateTimeFormat("fr-FR", { + year: "numeric", + month: "short", + day: "2-digit", + }).format(new Date(tablo.created_at))} + + ), + }, + { + key: "status", + label: "Statut", + value: ( + + {statusLabel} + + ), + }, + { + key: "progress", + label: "Progression", + value: ( + <> +
+
+
+
+ {progress.donePercentage}% + + ), + }, + ]; + + const tabs = TABS.map((tab) => + tab.id === "discussion" ? { ...tab, hasUnread: hasUnreadDiscussion } : tab + ); + + const discussionButton = + discussionAction?.kind === "link" ? ( + + ) : discussionAction?.kind === "button" ? ( + + ) : null; + + const inviteButton = + canInviteMembers && onOpenInviteDialog ? ( + + ) : null; + + const headerActions = + discussionButton || inviteButton ? ( + <> + {discussionButton} + {inviteButton} + + ) : undefined; + + return ( + onTabChange(tabId as SingleTabloTabId)} + isDiscussionView={activeTab === "discussion"} + headerActions={headerActions} + > + {children} + + ); +} diff --git a/packages/tablo-views/src/single-tablo/overviewLayout.ts b/packages/tablo-views/src/single-tablo/overviewLayout.ts new file mode 100644 index 0000000..6424602 --- /dev/null +++ b/packages/tablo-views/src/single-tablo/overviewLayout.ts @@ -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(items: T[]): T[] { + return [...new Set(items)]; +} + +function isObject(value: unknown): value is Record { + 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, + }; +} diff --git a/packages/tablo-views/src/single-tablo/overviewReorder.ts b/packages/tablo-views/src/single-tablo/overviewReorder.ts new file mode 100644 index 0000000..c62c48d --- /dev/null +++ b/packages/tablo-views/src/single-tablo/overviewReorder.ts @@ -0,0 +1,46 @@ +export function moveWithinZone(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( + 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, + }; +} diff --git a/packages/tablo-views/src/single-tablo/shared.tsx b/packages/tablo-views/src/single-tablo/shared.tsx new file mode 100644 index 0000000..2b879d3 --- /dev/null +++ b/packages/tablo-views/src/single-tablo/shared.tsx @@ -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", + }; + } +}