diff --git a/apps/main/src/pages/tablo-details.layout.test.tsx b/apps/main/src/pages/tablo-details.layout.test.tsx index e591c75..17c103e 100644 --- a/apps/main/src/pages/tablo-details.layout.test.tsx +++ b/apps/main/src/pages/tablo-details.layout.test.tsx @@ -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(, { + 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(, { + 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(, { + 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(, { route: "/tablos/tablo-1", diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index a9cd3e5..50dfa93 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -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(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 ( -
- {/* ── Header ──────────────────────────────────────────────────────── */} -
-
-
-
- {tablo.image ? ( - {tablo.name} - ) : ( - - )} -
-

{tablo.name}

-
+ <> + setSearchParams({ section: tabId })} + hasUnreadDiscussion={hasUnreadDiscussion} + discussionAction={{ kind: "link", to: `/chat/${tabloId}` }} + canInviteMembers={isAdmin} + onOpenInviteDialog={() => setIsShareDialogOpen(true)} + > + {activeSection === "overview" && ( + 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)} + /> + )} -
- - - Discussion - - {isAdmin && ( - - )} -
-
- - {/* ── Metadata bar ──────────────────────────────────────────────── */} -
-
- Rôle : - {isAdmin ? "Admin" : "Invité"} -
-
- Créé le : - - {new Intl.DateTimeFormat("fr-FR", { - year: "numeric", - month: "short", - day: "2-digit", - }).format(new Date(tablo.created_at))} - -
-
- Statut : - - {statusLabel} - -
-
- Progression : -
-
-
-
- {progress.donePercentage}% -
-
-
- - {/* ── Tab navigation ──────────────────────────────────────────────── */} -
-
-
- {TABS.map((tab) => { - const isActive = activeSection === tab.id; - return ( - - ); - })} -
-
-
- - {/* ── Tab content ─────────────────────────────────────────────────── */} -
- {activeSection === "overview" && - (() => { - const overviewBlocks: Record = { - description: ( -
-

- Description du projet -

-

- Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les - onglets ci-dessus pour naviguer entre les différentes sections. -

-
- ), - myTasks: ( -
-
-

- Mes tâches -

- -
-
- {myTabloTasks.length === 0 ? ( -
- Aucune tâche -
- ) : ( - visibleOverviewTasks.map((task) => ( -
setSearchParams({ section: "tasks" })} - > - -

- {task.title} -

-
- )) - )} - {myTabloTasks.length > 5 && ( - - )} -
-
- ), - files: ( -
-
-

Fichiers

- -
-
- {fileNames.length === 0 ? ( -

Aucun fichier

- ) : ( - fileNames.slice(0, 5).map((fileName) => ( -
-
- -
-
-

- {fileName} -

-
- -
- )) - )} -
-
- ), - info: ( -
-

Informations

-
-
-
Tâches
-
{tabloTasks.length}
-
-
-
Fichiers
-
{fileNames.length}
-
-
-
Statut
-
- {statusLabel} -
-
-
-
Rôle
-
- {isAdmin ? "Admin" : "Invité"} -
-
-
-
- ), - }; - - return ( - <> - {isAdmin && ( -
- {isLayoutEditMode && ( - - )} - -
- )} - -
-
{ - event.preventDefault(); - handleOverviewBlockDrop("left", overviewLayout.leftZone.length); - }} - > - {overviewLayout.leftZone.map((blockId, index) => ( -
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 && ( -
- - Glisser pour réorganiser -
- )} - {overviewBlocks[blockId]} -
- ))} - {isLayoutEditMode && overviewLayout.leftZone.length === 0 && ( -
- Déposez un bloc ici -
- )} -
- -
{ - event.preventDefault(); - handleOverviewBlockDrop("right", overviewLayout.rightZone.length); - }} - > - {overviewLayout.rightZone.map((blockId, index) => ( -
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 && ( -
- - Glisser pour réorganiser -
- )} - {overviewBlocks[blockId]} -
- ))} - {isLayoutEditMode && overviewLayout.rightZone.length === 0 && ( -
- Déposez un bloc ici -
- )} -
-
- - ); - })()} - - {activeSection === "tasks" && ( - ({ ...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" && ( - ({ ...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" && ( + ({ ...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" && ( +
+ !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" && ( -
- -
- )} - {activeSection === "events" && ( - ({ ...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 === "events" && ( + ({ ...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" && ( - - createTask({ - ...task, - status: task.status as "todo" | "in_progress" | "in_review" | "done", - }) - } - onCreateEtape={(params) => createEtape(params).then(() => undefined)} - isCreatingEtape={isCreatingEtape} - /> - )} + {activeSection === "etapes" && ( + + createTask({ + ...task, + status: task.status as "todo" | "in_progress" | "in_review" | "done", + }) + } + onCreateEtape={(params) => createEtape(params).then(() => undefined)} + isCreatingEtape={isCreatingEtape} + /> + )} - {activeSection === "roadmap" && ( - updateTask({ id: taskId, status })} - /> - )} -
+ {activeSection === "roadmap" && ( + updateTask({ id: taskId, status })} + /> + )} + {/* 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 = () => {
-
+ ); }; diff --git a/docs/superpowers/specs/2026-04-16-clients-exact-tablo-parity-design.md b/docs/superpowers/specs/2026-04-16-clients-exact-tablo-parity-design.md new file mode 100644 index 0000000..3b43b5a --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-clients-exact-tablo-parity-design.md @@ -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 diff --git a/packages/tablo-views/src/EtapesSection.tsx b/packages/tablo-views/src/EtapesSection.tsx index c671847..9f4d0f1 100644 --- a/packages/tablo-views/src/EtapesSection.tsx +++ b/packages/tablo-views/src/EtapesSection.tsx @@ -108,27 +108,29 @@ export function EtapesSection({ return (
{isAdmin && ( -
+
{ + event.preventDefault(); + void handleAddEtape(); + }} + > 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" /> -
+ )} {etapes.length === 0 ? ( diff --git a/packages/tablo-views/src/TabloTasksSection.tsx b/packages/tablo-views/src/TabloTasksSection.tsx index 0743baa..350a38c 100644 --- a/packages/tablo-views/src/TabloTasksSection.tsx +++ b/packages/tablo-views/src/TabloTasksSection.tsx @@ -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; 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} />
); diff --git a/packages/tablo-views/src/components/kanban/TaskModal.tsx b/packages/tablo-views/src/components/kanban/TaskModal.tsx index 6fea2df..b87066b 100644 --- a/packages/tablo-views/src/components/kanban/TaskModal.tsx +++ b/packages/tablo-views/src/components/kanban/TaskModal.tsx @@ -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 */}
+ {taskId && task && ( + + )}