fix: wire client portal interactions

This commit is contained in:
Arthur Belleville 2026-04-19 09:51:35 +02:00
parent a37c4ddf25
commit 46d2eb0277
No known key found for this signature in database
7 changed files with 865 additions and 78 deletions

View file

@ -10,12 +10,13 @@ export { ChatRoom };
const app = new Hono<{ Bindings: Env }>();
// CORS — allow the main app origins
// CORS — allow the web app origins that embed chat
app.use("*", cors({
origin: [
"http://localhost:5173",
"https://app.xtablo.com",
"https://app-staging.xtablo.com",
"https://clients.xtablo.com",
],
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["GET", "POST", "OPTIONS"],

View file

@ -1,4 +1,7 @@
VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
VITE_CHAT_WS_URL=wss://chat.xtablo.com
VITE_CHAT_API_URL=https://chat.xtablo.com
VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app

View file

@ -5,9 +5,11 @@ import { describe, expect, it } from "vitest";
const productionEnv = readFileSync(resolve(process.cwd(), ".env.production"), "utf8");
describe("clients production env", () => {
it("points the API URL to staging while client portal testing is in progress", () => {
it("points the API URL to staging while client portal testing is in progress and keeps chat endpoints configured", () => {
expect(productionEnv).toContain(
"VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app"
);
expect(productionEnv).toContain("VITE_CHAT_API_URL=https://chat.xtablo.com");
expect(productionEnv).toContain("VITE_CHAT_WS_URL=wss://chat.xtablo.com");
});
});

View file

@ -1,14 +1,114 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../test/testHelpers";
import { ClientTabloPage } from "./ClientTabloPage";
const {
apiGetMock,
apiPostMock,
apiPutMock,
apiDeleteMock,
updateTaskMock,
insertTaskMock,
deleteTaskMock,
supabaseFromMock,
} = vi.hoisted(() => {
const apiGetMock = vi.fn(async (url: string) => {
if (url.endsWith("/brief.pdf")) {
return {
status: 200,
data: { content: "test file content", contentType: "application/pdf" },
};
}
return { status: 200, data: { folders: [] } };
});
const apiPostMock = vi.fn(async () => ({
status: 200,
data: {
message: "ok",
fileName: "brief.pdf",
tabloId: "tablo-1",
folder: { id: "folder-1", name: "Livrable", description: "" },
},
}));
const apiPutMock = vi.fn(async () => ({
status: 200,
data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } },
}));
const apiDeleteMock = vi.fn(async () => ({ status: 200, data: { message: "ok" } }));
const createUpdateBuilder = () => {
const builder = {
error: null as null,
eq: vi.fn(() => builder),
select: vi.fn(() => ({
single: async () => ({ data: { id: "task-1" }, error: null }),
})),
};
return builder;
};
const updateTaskMock = vi.fn(() => createUpdateBuilder());
const insertTaskMock = vi.fn(() => ({
select: () => ({
single: async () => ({ data: { id: "task-created" }, error: null }),
}),
}));
const deleteTaskMock = vi.fn(() => ({
eq: vi.fn(async () => ({ error: null })),
}));
const supabaseFromMock = vi.fn(() => ({
insert: insertTaskMock,
update: updateTaskMock,
delete: deleteTaskMock,
}));
return {
apiGetMock,
apiPostMock,
apiPutMock,
apiDeleteMock,
updateTaskMock,
insertTaskMock,
deleteTaskMock,
supabaseFromMock,
};
});
let latestTabloTasksSectionProps: Record<string, unknown> | null = null;
let latestEtapesSectionProps: Record<string, unknown> | null = null;
let latestRoadmapSectionProps: Record<string, unknown> | null = null;
let latestTabloFilesSectionProps: Record<string, unknown> | null = null;
vi.mock("@xtablo/shared", async (importOriginal) => {
const actual = await importOriginal<typeof import("@xtablo/shared")>();
return {
...actual,
buildApi: () => ({
create: () => ({
get: apiGetMock,
post: apiPostMock,
put: apiPutMock,
delete: apiDeleteMock,
}),
}),
};
});
vi.mock("../lib/supabase", () => ({
supabase: {
from: supabaseFromMock,
},
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQuery: ({ queryKey }: { queryKey: string[] }) => {
useQuery: ({ queryKey, queryFn }: { queryKey: string[]; queryFn?: () => Promise<unknown> }) => {
if (queryKey[0] === "client-tablo-folders" && queryFn) {
void queryFn();
}
switch (queryKey[0]) {
case "client-tablo":
return {
@ -84,16 +184,321 @@ vi.mock("@xtablo/tablo-views", async (importOriginal) => {
return {
...actual,
EtapesSection: () => <div>Etapes section</div>,
RoadmapSection: () => <div>Roadmap section</div>,
EtapesSection: (props: Record<string, unknown>) => {
latestEtapesSectionProps = props;
return (
<div>
<div>Etapes section</div>
<button
type="button"
onClick={() =>
(
props.onCreateTask as
| ((task: {
tablo_id: string;
title: string;
status: string;
parent_task_id: string;
is_parent: boolean;
position: number;
}) => void)
| undefined
)?.({
tablo_id: "tablo-1",
title: "Task from etape",
status: "todo",
parent_task_id: "etape-1",
is_parent: false,
position: 0,
})
}
>
Créer tâche d'étape test
</button>
<button
type="button"
onClick={() =>
(
props.onTaskStatusChange as
| ((taskId: string, status: string) => void)
| undefined
)?.("task-1", "done")
}
>
Terminer tâche d'étape test
</button>
</div>
);
},
RoadmapSection: (props: Record<string, unknown>) => {
latestRoadmapSectionProps = props;
return (
<div>
<div>Roadmap section</div>
<button
type="button"
onClick={() =>
(
props.onTaskStatusChange as
| ((taskId: string, status: string) => void)
| undefined
)?.("task-1", "done")
}
>
Changer statut roadmap test
</button>
</div>
);
},
TabloDiscussionSection: () => <div>Discussion section</div>,
TabloEventsSection: () => <div>Events section</div>,
TabloFilesSection: () => <div>Files section</div>,
TabloTasksSection: () => <div>Tasks section</div>,
TabloFilesSection: (props: Record<string, unknown>) => {
latestTabloFilesSectionProps = props;
return (
<div>
<div>Files section</div>
<button
type="button"
onClick={() =>
(
props.onCreateFile as
| ((params: {
tabloId: string;
fileName: string;
data: { content: string; contentType: string };
}) => void)
| undefined
)?.({
tabloId: "tablo-1",
fileName: "brief.pdf",
data: { content: "data:application/pdf;base64,AAAA", contentType: "application/pdf" },
})
}
>
Créer fichier test
</button>
<button
type="button"
onClick={() =>
(
props.onDownloadFile as
| ((params: { tabloId: string; fileName: string }) => void)
| undefined
)?.({ tabloId: "tablo-1", fileName: "brief.pdf" })
}
>
Télécharger fichier test
</button>
<button
type="button"
onClick={() =>
(
props.onCreateFolder as
| ((params: {
tabloId: string;
name: string;
description: string;
createdBy: string;
}) => void)
| undefined
)?.({
tabloId: "tablo-1",
name: "Livrable",
description: "Desc",
createdBy: "client-user-1",
})
}
>
Créer livrable test
</button>
<button
type="button"
onClick={() =>
(
props.onUpdateFolder as
| ((params: {
tabloId: string;
folderId: string;
name: string;
description: string;
}) => void)
| undefined
)?.({
tabloId: "tablo-1",
folderId: "folder-1",
name: "Livrable mis à jour",
description: "Desc",
})
}
>
Modifier livrable test
</button>
<button
type="button"
onClick={() =>
(
props.onDeleteFolder as
| ((params: {
tabloId: string;
folderId: string;
folderName: string;
}) => void)
| undefined
)?.({
tabloId: "tablo-1",
folderId: "folder-1",
folderName: "Livrable",
})
}
>
Supprimer livrable test
</button>
</div>
);
},
TabloTasksSection: (props: Record<string, unknown>) => {
latestTabloTasksSectionProps = props;
return (
<div>
<div>Tasks section</div>
<button
type="button"
onClick={() =>
(
props.onCreateTask as
| ((task: Record<string, unknown>) => void)
| undefined
)?.({
tablo_id: "tablo-1",
title: "Task from tasks tab",
status: "todo",
position: 1,
parent_task_id: "etape-1",
is_parent: false,
})
}
>
Créer tâche test
</button>
<button
type="button"
onClick={() =>
(
props.onUpdateTask as
| ((task: Record<string, unknown>) => void)
| undefined
)?.({ id: "task-1", tablo_id: "tablo-1", title: "Updated task title" })
}
>
Modifier tâche test
</button>
<button
type="button"
onClick={() => (props.onDeleteTask as ((taskId: string) => void) | undefined)?.("task-1")}
>
Supprimer tâche test
</button>
<button
type="button"
onClick={() =>
(
props.onUpdateTaskPositions as
| ((updates: Array<{ id: string; position: number; status: string }>) => void)
| undefined
)?.([{ id: "task-1", position: 7, status: "done" }])
}
>
Déplacer la tâche test
</button>
</div>
);
},
};
});
describe("ClientTabloPage parity shell", () => {
beforeEach(() => {
window.URL.createObjectURL = vi.fn(() => "blob:test");
window.URL.revokeObjectURL = vi.fn();
HTMLAnchorElement.prototype.click = vi.fn();
apiGetMock.mockClear();
apiPostMock.mockClear();
apiPutMock.mockClear();
apiDeleteMock.mockClear();
updateTaskMock.mockClear();
insertTaskMock.mockClear();
deleteTaskMock.mockClear();
supabaseFromMock.mockClear();
latestTabloTasksSectionProps = null;
latestEtapesSectionProps = null;
latestRoadmapSectionProps = null;
latestTabloFilesSectionProps = null;
});
it("requests folders from the tablo-data API route", () => {
renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1",
path: "/tablo/:tabloId",
});
expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders");
});
it("wires real task mutation callbacks throughout the client task surfaces", async () => {
const { user } = renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1",
path: "/tablo/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Étapes" }));
expect(latestEtapesSectionProps?.onCreateTask).toBeTypeOf("function");
expect(latestEtapesSectionProps?.onTaskStatusChange).toBeTypeOf("function");
await user.click(screen.getByRole("button", { name: "Créer tâche d'étape test" }));
await user.click(screen.getByRole("button", { name: "Terminer tâche d'étape test" }));
await user.click(screen.getByRole("button", { name: "Tâches" }));
expect(latestTabloTasksSectionProps?.onCreateTask).toBeTypeOf("function");
expect(latestTabloTasksSectionProps?.onUpdateTask).toBeTypeOf("function");
expect(latestTabloTasksSectionProps?.onDeleteTask).toBeTypeOf("function");
expect(latestTabloTasksSectionProps?.onUpdateTaskPositions).toBeTypeOf("function");
await user.click(screen.getByRole("button", { name: "Créer tâche test" }));
await user.click(screen.getByRole("button", { name: "Modifier tâche test" }));
await user.click(screen.getByRole("button", { name: "Supprimer tâche test" }));
await user.click(screen.getByRole("button", { name: "Déplacer la tâche test" }));
await user.click(screen.getByRole("button", { name: "Roadmap" }));
expect(latestRoadmapSectionProps?.onTaskStatusChange).toBeTypeOf("function");
await user.click(screen.getByRole("button", { name: "Changer statut roadmap test" }));
await waitFor(() => {
expect(supabaseFromMock).toHaveBeenCalledWith("tasks");
expect(insertTaskMock).toHaveBeenCalledTimes(2);
expect(insertTaskMock).toHaveBeenCalledWith(
expect.objectContaining({
tablo_id: "tablo-1",
title: "Task from etape",
status: "todo",
assignee_id: null,
position: 0,
parent_task_id: "etape-1",
is_parent: false,
description: null,
due_date: null,
})
);
expect(updateTaskMock).toHaveBeenCalledWith({ title: "Updated task title" });
expect(updateTaskMock).toHaveBeenCalledWith({ position: 7, status: "done" });
expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" });
expect(deleteTaskMock).toHaveBeenCalledTimes(1);
});
});
it("renders the main-route style header metadata and discussion CTA", () => {
renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1",
@ -143,4 +548,57 @@ describe("ClientTabloPage parity shell", () => {
expect(screen.getByText("Informations")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Ajouter" })).not.toBeInTheDocument();
});
it("lets the client quickly toggle a task from the overview card", async () => {
const { user } = renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1",
path: "/tablo/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Prepare proposal" }));
await waitFor(() => {
expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" });
});
});
it("wires file and folder actions in the client files tab while keeping file deletion disabled", async () => {
const { user } = renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1",
path: "/tablo/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Fichiers" }));
expect(latestTabloFilesSectionProps?.isReadOnly).toBe(false);
expect(latestTabloFilesSectionProps?.onCreateFile).toBeTypeOf("function");
expect(latestTabloFilesSectionProps?.onDownloadFile).toBeTypeOf("function");
expect(latestTabloFilesSectionProps?.onCreateFolder).toBeTypeOf("function");
expect(latestTabloFilesSectionProps?.onUpdateFolder).toBeTypeOf("function");
expect(latestTabloFilesSectionProps?.onDeleteFolder).toBeTypeOf("function");
expect(latestTabloFilesSectionProps?.onDeleteFile).toBeUndefined();
await user.click(screen.getByRole("button", { name: "Créer fichier test" }));
await user.click(screen.getByRole("button", { name: "Télécharger fichier test" }));
await user.click(screen.getByRole("button", { name: "Créer livrable test" }));
await user.click(screen.getByRole("button", { name: "Modifier livrable test" }));
await user.click(screen.getByRole("button", { name: "Supprimer livrable test" }));
await waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith(
"/api/v1/tablo-data/tablo-1/file/brief.pdf",
{ content: "data:application/pdf;base64,AAAA", contentType: "application/pdf" }
);
expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/brief.pdf");
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders", {
name: "Livrable",
description: "Desc",
});
expect(apiPutMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1", {
name: "Livrable mis à jour",
description: "Desc",
});
expect(apiDeleteMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1");
});
});
});

View file

@ -1,8 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cn } from "@xtablo/shared";
import { buildApi } from "@xtablo/shared";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import type { Etape, KanbanTask, TabloFolder, UserTablo } from "@xtablo/shared-types";
import type {
Etape,
KanbanTask,
KanbanTaskUpdate,
TabloFolder,
TaskStatus,
UserTablo,
} from "@xtablo/shared-types";
import {
FolderIcon,
} from "lucide-react";
@ -135,13 +142,254 @@ function useClientTabloFolders(tabloId: string, accessToken: string | undefined)
return useQuery<TabloFolder[]>({
queryKey: ["client-tablo-folders", tabloId],
queryFn: async () => {
const { data } = await api.get<{ folders: TabloFolder[] }>(`/api/v1/tablo-folders/${tabloId}`);
const { data } = await api.get<{ folders: TabloFolder[] }>(
`/api/v1/tablo-data/${tabloId}/folders`
);
return data.folders ?? [];
},
enabled: !!tabloId && !!accessToken,
});
}
const invalidateClientFileQueries = (queryClient: ReturnType<typeof useQueryClient>, tabloId: string) => {
queryClient.invalidateQueries({ queryKey: ["client-tablo-files", tabloId] });
queryClient.invalidateQueries({ queryKey: ["client-tablo-folders", tabloId] });
};
function useClientCreateFile(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: {
tabloId: string;
fileName: string;
data: { content: string; contentType: string };
}) => {
const response = await api.post(`/api/v1/tablo-data/${params.tabloId}/file/${params.fileName}`, params.data);
if (response.status !== 200) {
throw new Error("Failed to create file");
}
return response.data;
},
onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
});
}
function useClientDownloadFile(accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
return useMutation({
mutationFn: async ({ tabloId, fileName }: { tabloId: string; fileName: string }) => {
const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`);
if (response.status !== 200) {
throw new Error("Failed to download file");
}
const fileData = response.data as { content: string; contentType?: string };
let blob: Blob;
if (fileData.content.startsWith("data:")) {
const fileResponse = await fetch(fileData.content);
blob = await fileResponse.blob();
} else {
blob = new Blob([fileData.content], {
type: fileData.contentType || "application/octet-stream",
});
}
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
});
}
function useClientCreateFolder(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: {
tabloId: string;
name: string;
description: string;
createdBy: string;
}) => {
const response = await api.post(`/api/v1/tablo-data/${params.tabloId}/folders`, {
name: params.name,
description: params.description,
});
if (response.status !== 200) {
throw new Error("Failed to create folder");
}
return response.data;
},
onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
});
}
function useClientUpdateFolder(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: {
tabloId: string;
folderId: string;
name: string;
description: string;
}) => {
const response = await api.put(`/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`, {
name: params.name,
description: params.description,
});
if (response.status !== 200) {
throw new Error("Failed to update folder");
}
return response.data;
},
onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
});
}
function useClientDeleteFolder(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: {
tabloId: string;
folderId: string;
folderName: string;
}) => {
const response = await api.delete(`/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`);
if (response.status !== 200) {
throw new Error("Failed to delete folder");
}
return response.data;
},
onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
});
}
type ClientTaskCreateInput = {
tablo_id: string;
title: string;
status?: TaskStatus | string;
parent_task_id?: string | null;
is_parent?: boolean;
position?: number;
description?: string | null;
assignee_id?: string | null;
due_date?: string | null;
};
const invalidateClientTaskQueries = (queryClient: ReturnType<typeof useQueryClient>, tabloId: string) => {
queryClient.invalidateQueries({ queryKey: ["client-tasks", tabloId] });
};
function useClientCreateTask(tabloId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (task: ClientTaskCreateInput) => {
const { data, error } = await supabase
.from("tasks")
.insert({
tablo_id: task.tablo_id,
title: task.title,
status: (task.status as TaskStatus | undefined) ?? "todo",
assignee_id: task.assignee_id ?? null,
position: task.position ?? 0,
parent_task_id: task.parent_task_id ?? null,
is_parent: task.is_parent ?? false,
description: task.description ?? null,
due_date: task.due_date ?? null,
})
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
});
}
function useClientUpdateTask(tabloId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, tablo_id: _tabloId, ...updates }: KanbanTaskUpdate & { id: string; tablo_id?: string }) => {
const { data, error } = await supabase
.from("tasks")
.update(updates)
.eq("id", id)
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
});
}
function useClientDeleteTask(tabloId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (taskId: string) => {
const { error } = await supabase.from("tasks").delete().eq("id", taskId);
if (error) throw error;
return taskId;
},
onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
});
}
function useClientUpdateTaskPositions(tabloId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
updates: Array<{
id: string;
position: number;
status?: TaskStatus;
parent_task_id?: string | null;
}>
) => {
const results = await Promise.all(
updates.map(({ id, position, status, parent_task_id }) =>
supabase
.from("tasks")
.update({
position,
...(status && { status }),
...(parent_task_id !== undefined ? { parent_task_id } : {}),
})
.eq("id", id)
)
);
const errors = results.filter((result) => result.error);
if (errors.length > 0) {
throw new Error("Failed to update some task positions");
}
return updates;
},
onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
});
}
function getStatusConfig(status: string) {
switch (status) {
case "in_progress":
@ -202,6 +450,15 @@ export function ClientTabloPage() {
const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken);
const { data: filesData, isLoading: filesLoading, error: filesError } = useClientTabloFiles(tabloId ?? "", accessToken);
const { data: folders = [], isLoading: foldersLoading, error: foldersError } = useClientTabloFolders(tabloId ?? "", accessToken);
const { mutate: createTask } = useClientCreateTask(tabloId ?? "");
const { mutate: updateTask } = useClientUpdateTask(tabloId ?? "");
const { mutate: deleteTask } = useClientDeleteTask(tabloId ?? "");
const { mutate: updateTaskPositions } = useClientUpdateTaskPositions(tabloId ?? "");
const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? "", accessToken);
const { mutateAsync: downloadFile } = useClientDownloadFile(accessToken);
const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? "", accessToken);
const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? "", accessToken);
const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? "", accessToken);
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
@ -263,12 +520,36 @@ export function ClientTabloPage() {
</div>
) : (
tasks.slice(0, 5).map((task) => (
<div key={task.id} className="flex items-center gap-3 p-4">
<div className="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
<p className="text-sm font-medium truncate text-gray-900 dark:text-gray-100">
<button
key={task.id}
type="button"
onClick={() =>
updateTask({
id: task.id,
status: task.status === "done" ? "todo" : "done",
})
}
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
>
<div
className={cn(
"w-5 h-5 rounded-full border-2 shrink-0",
task.status === "done"
? "bg-green-500 border-green-500"
: "border-gray-300 dark:border-gray-600"
)}
/>
<p
className={cn(
"text-sm font-medium truncate",
task.status === "done"
? "text-gray-400 line-through"
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
</p>
</div>
</button>
))
)}
</div>
@ -334,8 +615,9 @@ export function ClientTabloPage() {
tabloTasks={tasks}
tabloId={tablo.id}
isAdmin={false}
onCreateTask={() => {}}
onCreateTask={(task) => createTask(task)}
onCreateEtape={async () => {}}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
/>
)}
@ -347,6 +629,10 @@ export function ClientTabloPage() {
members={members}
etapes={etapes}
currentUser={currentUser}
onCreateTask={(task) => createTask(task)}
onUpdateTask={(task) => updateTask(task)}
onDeleteTask={(taskId) => deleteTask(taskId)}
onUpdateTaskPositions={(updates) => updateTaskPositions(updates)}
/>
)}
@ -354,7 +640,10 @@ export function ClientTabloPage() {
<TabloFilesSection
tablo={tablo}
isAdmin={false}
isReadOnly={true}
isReadOnly={false}
canUploadFiles={true}
canManageFolders={true}
canDeleteFiles={false}
currentUserId={currentUserId}
fileNames={fileNames}
filesLoading={filesLoading}
@ -364,6 +653,11 @@ export function ClientTabloPage() {
foldersError={foldersError instanceof Error ? foldersError : null}
currentUser={currentUser}
members={members}
onCreateFile={(params) => createFile(params)}
onDownloadFile={(params) => downloadFile(params)}
onCreateFolder={(params) => createFolder(params)}
onUpdateFolder={(params) => updateFolder(params)}
onDeleteFolder={(params) => deleteFolder(params)}
/>
)}
@ -393,7 +687,7 @@ export function ClientTabloPage() {
<RoadmapSection
tabloTasks={tasks}
onDateClick={() => {}}
onTaskStatusChange={() => {}}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
/>
)}
</SingleTabloView>

View file

@ -1,5 +1,5 @@
import { cn } from "@xtablo/shared";
import type { Etape, KanbanTask } from "@xtablo/shared-types";
import type { Etape, KanbanTask, TaskStatus } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import {
@ -30,6 +30,7 @@ interface EtapesSectionProps {
position: number;
}) => void;
onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise<void>;
onTaskStatusChange?: (taskId: string, status: TaskStatus) => void;
onUpdateEtape?: (params: { id: string; tabloId: string; title: string }) => Promise<void>;
onDeleteEtape?: (params: { id: string; tabloId: string }) => Promise<void>;
isCreatingEtape?: boolean;
@ -44,6 +45,7 @@ export function EtapesSection({
isAdmin,
onCreateTask,
onCreateEtape,
onTaskStatusChange,
onUpdateEtape,
onDeleteEtape,
isCreatingEtape = false,
@ -381,7 +383,18 @@ export function EtapesSection({
{childTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
className={cn(
"flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 transition-colors",
onTaskStatusChange
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
)}
onClick={() =>
onTaskStatusChange?.(
task.id,
task.status === "done" ? "todo" : "done"
)
}
>
{task.status === "done" ? (
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />

View file

@ -270,8 +270,9 @@ const FolderDialog = ({
const FolderSection = ({
folder,
files,
isAdmin,
isReadOnly,
canManageFolders,
canUploadFiles,
canDeleteFiles,
isOpen,
onToggle,
onEdit,
@ -285,8 +286,9 @@ const FolderSection = ({
}: {
folder: TabloFolder;
files: string[];
isAdmin: boolean;
isReadOnly: boolean;
canManageFolders: boolean;
canUploadFiles: boolean;
canDeleteFiles: boolean;
isOpen: boolean;
onToggle: () => void;
onEdit: () => void;
@ -341,7 +343,7 @@ const FolderSection = ({
</div>
</div>
<div className="flex items-center space-x-2">
{isAdmin && !isReadOnly && (
{canManageFolders && (
<>
<Button
size="sm"
@ -378,47 +380,49 @@ const FolderSection = ({
<CollapsibleContent>
<div className="border-t border-border p-4 space-y-4">
{/* File Upload Area */}
<div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full py-4 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors rounded-md cursor-pointer flex items-center justify-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-amber-500"></div>
<span className="text-sm text-muted-foreground">Téléchargement...</span>
</>
) : (
<>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span className="text-sm text-muted-foreground">
Ajouter un fichier à ce livrable
</span>
</>
)}
</button>
</div>
{canUploadFiles && (
<div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full py-4 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors rounded-md cursor-pointer flex items-center justify-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-amber-500"></div>
<span className="text-sm text-muted-foreground">Téléchargement...</span>
</>
) : (
<>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span className="text-sm text-muted-foreground">
Ajouter un fichier à ce livrable
</span>
</>
)}
</button>
</div>
)}
{/* Files List */}
{files.length > 0 ? (
@ -427,7 +431,7 @@ const FolderSection = ({
<FileItem
key={fileName}
displayName={getFileNameWithoutFolder(fileName)}
canDelete={true}
canDelete={canDeleteFiles}
onDownload={() => onDownloadFile(fileName)}
onDelete={() => onDeleteFile(fileName)}
isDownloading={downloadingFile === fileName}
@ -483,6 +487,9 @@ interface TabloFilesSectionProps {
isCancellingInvite?: boolean;
isCreatingFolder?: boolean;
isUpdatingFolder?: boolean;
canUploadFiles?: boolean;
canManageFolders?: boolean;
canDeleteFiles?: boolean;
onCreateFile?: (params: { tabloId: string; fileName: string; data: { content: string; contentType: string } }) => Promise<void>;
onDeleteFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
onDownloadFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
@ -512,6 +519,9 @@ export const TabloFilesSection = ({
isCancellingInvite,
isCreatingFolder = false,
isUpdatingFolder = false,
canUploadFiles,
canManageFolders,
canDeleteFiles,
onCreateFile,
onDeleteFile,
onDownloadFile,
@ -534,6 +544,9 @@ export const TabloFilesSection = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const folderIds = useMemo(() => new Set(folders.map((folder) => folder.id)), [folders]);
const allowUploadFiles = canUploadFiles ?? !isReadOnly;
const allowManageFolders = canManageFolders ?? (isAdmin && !isReadOnly);
const allowDeleteFiles = canDeleteFiles ?? true;
// Organize files by folder
const { filesInFolders, unorganizedFiles } = useMemo(() => {
@ -805,7 +818,7 @@ export const TabloFilesSection = ({
)}
{/* Create Folder Button - Admin Only */}
{isAdmin && !isReadOnly && (
{allowManageFolders && (
<div className="flex justify-end">
<Button onClick={handleCreateFolder} variant="outline" className="gap-2">
<FolderPlusIcon className="w-4 h-4" />
@ -836,8 +849,9 @@ export const TabloFilesSection = ({
key={folder.id}
folder={folder}
files={filesInFolders.get(folder.id) || []}
isAdmin={isAdmin}
isReadOnly={isReadOnly}
canManageFolders={allowManageFolders}
canUploadFiles={allowUploadFiles}
canDeleteFiles={allowDeleteFiles}
isOpen={openFolders.has(folder.id)}
onToggle={() => toggleFolder(folder.id)}
onEdit={() => handleEditFolder(folder)}
@ -879,7 +893,7 @@ export const TabloFilesSection = ({
</div>
{/* File Upload Area */}
{!selectedFile ? (
{allowUploadFiles && !selectedFile ? (
<div className="space-y-3">
<input
ref={fileInputRef}
@ -915,7 +929,7 @@ export const TabloFilesSection = ({
</div>
</button>
</div>
) : (
) : allowUploadFiles && selectedFile ? (
<div className="space-y-3">
<div className="flex items-center space-x-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
@ -979,11 +993,13 @@ export const TabloFilesSection = ({
</button>
</div>
</div>
)}
) : null}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Taille maximale par fichier: 20MB
</p>
{allowUploadFiles && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Taille maximale par fichier: 20MB
</p>
)}
{/* Unorganized Files List */}
{unorganizedFiles.length > 0 && (
@ -992,7 +1008,7 @@ export const TabloFilesSection = ({
<FileItem
key={fileName}
displayName={getFileNameWithoutFolder(fileName)}
canDelete={true}
canDelete={allowDeleteFiles}
onDownload={() => handleDownloadFile(fileName)}
onDelete={() => handleDeleteFile(fileName)}
isDownloading={downloadingFile === fileName}