fix: wire client portal interactions
This commit is contained in:
parent
a37c4ddf25
commit
46d2eb0277
7 changed files with 865 additions and 78 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue