Merge pull request #60 from artslidd/develop

Develop
This commit is contained in:
Arthur Belleville 2026-03-07 19:27:47 +01:00 committed by GitHub
commit 1533a22a11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1069 additions and 333 deletions

View file

@ -1,6 +1,7 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
@ -14,6 +15,7 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { clearTabloDataCachesForTests } from "../../routers/tablo_data.js";
import type { TestUserData } from "../helpers/dbSetup.js";
import { getTestUser } from "../helpers/dbSetup.js";
@ -103,11 +105,21 @@ describe("TabloData Endpoint", () => {
// Mock DeleteObjectCommand (used by deleteTabloFile)
s3Mock.on(DeleteObjectCommand).resolves({});
// Mock HeadObjectCommand (used by deleteTabloFile ownership checks)
s3Mock.on(HeadObjectCommand).callsFake((input) => {
const key = input.Key ?? "";
if (key.includes("temp-uploaded")) {
return Promise.resolve({ Metadata: { "uploaded-by": temporaryUser.userId } });
}
return Promise.resolve({ Metadata: { "uploaded-by": ownerUser.userId } });
});
});
beforeEach(() => {
// Reset folder metadata before each test
mockFolderMetadata = { folders: [], version: 1 };
clearTabloDataCachesForTests();
});
describe("GET /tablo-data/:tabloId/filenames - Owner Access", () => {
@ -374,7 +386,7 @@ describe("TabloData Endpoint", () => {
});
});
describe("DELETE /tablo-data/:tabloId/:fileName - Delete File (Admin Only)", () => {
describe("DELETE /tablo-data/:tabloId/:fileName - Delete File (Admin or Uploader)", () => {
// Helper function to delete file
const deleteTabloFileRequest = async (
user: TestUserData,
@ -420,9 +432,7 @@ describe("TabloData Endpoint", () => {
expect(data.message).toBe("File deleted successfully");
});
it("should deny owner from deleting file from temp's tablo (regularUserCheck blocks temporary owner)", async () => {
// Owner has admin access to test_tablo_temp_shared_admin
// BUT regularUserCheck blocks access to tablos owned by temporary users
it("should allow owner to delete file from temp's shared tablo when owner is admin member", async () => {
const res = await deleteTabloFileRequest(
ownerUser,
client,
@ -430,14 +440,12 @@ describe("TabloData Endpoint", () => {
"test-file.pdf"
);
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toBe("You are not an admin of this tablo");
expect(res.status).toBe(200);
});
});
describe("Temp User - Blocked by regularUserCheck", () => {
it("should deny temp user from deleting file from their own tablo (regularUserCheck)", async () => {
describe("Temp User Access (Member/Uploader Rules)", () => {
it("should allow temp user to delete file from their own tablo", async () => {
const res = await deleteTabloFileRequest(
temporaryUser,
client,
@ -445,12 +453,21 @@ describe("TabloData Endpoint", () => {
"test-file.pdf"
);
// Temporary users are blocked by regularUserCheck middleware
expect(res.status).toBe(401);
expect(res.status).toBe(200);
});
it("should deny temp user from deleting file from owner's shared tablo (regularUserCheck)", async () => {
// Even though temp has access, regularUserCheck blocks temporary users
it("should allow temp user to delete their own uploaded file in shared tablo", async () => {
const res = await deleteTabloFileRequest(
temporaryUser,
client,
"test_tablo_owner_shared",
"temp-uploaded.pdf"
);
expect(res.status).toBe(200);
});
it("should deny temp user from deleting another user's file in shared tablo", async () => {
const res = await deleteTabloFileRequest(
temporaryUser,
client,
@ -458,8 +475,7 @@ describe("TabloData Endpoint", () => {
"test-file.pdf"
);
// Temporary users are blocked by regularUserCheck middleware
expect(res.status).toBe(401);
expect(res.status).toBe(403);
});
});
@ -1094,7 +1110,9 @@ describe("TabloData Endpoint", () => {
});
});
describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin Only)", () => {
describe(
"DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)",
() => {
it("should allow admin to delete file with nested path", async () => {
const res = await deleteNestedFileRequest(
ownerUser,
@ -1120,7 +1138,7 @@ describe("TabloData Endpoint", () => {
expect(res.status).toBe(200);
});
it("should deny temp user from deleting nested file (regularUserCheck)", async () => {
it("should allow temp user to delete nested file from their own tablo", async () => {
const res = await deleteNestedFileRequest(
temporaryUser,
client,
@ -1128,12 +1146,10 @@ describe("TabloData Endpoint", () => {
"folder-123/file.pdf"
);
expect(res.status).toBe(401);
expect(res.status).toBe(200);
});
it("should deny non-admin from deleting nested file", async () => {
// Owner has admin access to test_tablo_temp_shared_admin
// BUT regularUserCheck blocks access to tablos owned by temporary users
it("should allow admin member to delete nested file in shared tablo", async () => {
const res = await deleteNestedFileRequest(
ownerUser,
client,
@ -1141,7 +1157,7 @@ describe("TabloData Endpoint", () => {
"folder/file.pdf"
);
expect(res.status).toBe(403);
expect(res.status).toBe(200);
});
it("should deny unauthenticated nested file deletion", async () => {

View file

@ -174,32 +174,35 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) =>
const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const { data: tabloAccess, error: isMemberError } = await supabase
.from("tablo_access")
.select("*")
.select("id")
.eq("tablo_id", tabloId)
.eq("user_id", userId)
.eq("is_active", true);
.eq("is_active", true)
.maybeSingle();
if (isMemberError) {
return false;
}
return tabloAccess?.length > 0;
return !!tabloAccess;
};
const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const { data: tabloAccess, error: isAdminError } = await supabase
.from("tablo_access")
.select("*")
.select("id")
.eq("tablo_id", tabloId)
.eq("user_id", userId)
.eq("is_active", true)
.eq("is_admin", true);
.eq("is_admin", true)
.maybeSingle();
// unique_tablo_access ensures at most one row
if (isAdminError) {
return false;
}
return tabloAccess?.length > 0;
return !!tabloAccess;
};
export const checkTabloMember = async (c: Context, next: Next) => {

View file

@ -231,9 +231,33 @@ const deleteTablo = factory.createHandlers(async (c) => {
return c.json({ error: "You are not authorized to delete this tablo" }, 403);
}
const deletedAt = new Date().toISOString();
const { error: tasksSoftDeleteError } = await supabase
.from("tasks")
.update({ deleted_at: deletedAt })
.eq("tablo_id", id)
.is("deleted_at", null);
if (tasksSoftDeleteError) {
// Backward compatibility for environments where tasks.deleted_at is not migrated yet.
const isMissingDeletedAtColumn =
tasksSoftDeleteError.code === "42703" ||
tasksSoftDeleteError.message?.toLowerCase().includes("deleted_at");
if (isMissingDeletedAtColumn) {
const { error: tasksDeleteError } = await supabase.from("tasks").delete().eq("tablo_id", id);
if (tasksDeleteError) {
return c.json({ error: tasksDeleteError.message }, 500);
}
} else {
return c.json({ error: tasksSoftDeleteError.message }, 500);
}
}
const { error } = await supabase
.from("tablos")
.update({ deleted_at: new Date().toISOString() })
.update({ deleted_at: deletedAt })
.eq("id", id);
if (error) {
@ -262,8 +286,11 @@ const inviteToTablo = (
const tabloId = c.req.param("tabloId");
const { email: recipientmail } = await c.req.json();
const recipientEmail = String(recipientmail || "")
.trim()
.toLowerCase();
if (sender.email === recipientmail) {
if (sender.email?.toLowerCase() === recipientEmail) {
return c.json({ error: "You cannot invite yourself" }, 400);
}
@ -292,7 +319,7 @@ const inviteToTablo = (
const introEmail = introConfigData?.config?.intro_email;
const { error } = await supabase.from("tablo_invites").insert({
invited_email: recipientmail,
invited_email: recipientEmail,
tablo_id: tabloId,
invited_by: sender.id,
invite_token: token,
@ -311,7 +338,7 @@ const inviteToTablo = (
const { data: recipientUser, error: recipientError } = await supabase
.from("profiles")
.select("id")
.eq("email", recipientmail)
.eq("email", recipientEmail)
.maybeSingle();
if (recipientError) {
@ -324,7 +351,7 @@ const inviteToTablo = (
supabase,
streamServerClient,
transporter,
recipientmail,
recipientEmail,
sender.email
);
@ -378,7 +405,7 @@ const inviteToTablo = (
// Let the user know that they have been invited to the tablo
await transporter.sendMail({
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
to: recipientEmail,
subject: "Vous avez été invité à un tablo",
html: `
${introEmail ? `<p>${introEmail}</p>` : ""}
@ -403,7 +430,8 @@ ${introEmail ? `<p>${introEmail}</p>` : ""}
const cancelPendingInvite = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const tabloId = c.req.param("tabloId");
@ -432,6 +460,27 @@ const cancelPendingInvite = (
return c.json({ error: "Invite is no longer pending" }, 400);
}
const isInvitee = invite.invited_email?.toLowerCase() === user.email?.toLowerCase();
const { data: adminAccess, error: adminAccessError } = await supabase
.from("tablo_access")
.select("id")
.eq("tablo_id", tabloId)
.eq("user_id", user.id)
.eq("is_active", true)
.eq("is_admin", true)
.maybeSingle();
if (adminAccessError) {
return c.json({ error: adminAccessError.message }, 500);
}
const isAdmin = !!adminAccess;
if (!isInvitee && !isAdmin) {
return c.json({ error: "You are not authorized to cancel this invite" }, 403);
}
const { error: cancelError } = await supabase
.from("tablo_invites")
.update({ is_pending: false })
@ -471,6 +520,112 @@ const cancelPendingInvite = (
return c.json({ message: "Invite cancelled successfully" });
});
const getPendingInvitesForCurrentUser = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { data: pendingInvites, error: pendingInvitesError } = await supabase
.from("tablo_invites")
.select("id, tablo_id, created_at")
.eq("invited_email", user.email?.toLowerCase())
.eq("is_pending", true)
.order("created_at", { ascending: false });
if (pendingInvitesError) {
return c.json({ error: pendingInvitesError.message }, 500);
}
const tabloIds = Array.from(new Set((pendingInvites || []).map((invite) => invite.tablo_id)));
let tablosById = new Map<string, string>();
if (tabloIds.length > 0) {
const { data: tablos, error: tablosError } = await supabase
.from("tablos")
.select("id, name")
.in("id", tabloIds);
if (tablosError) {
return c.json({ error: tablosError.message }, 500);
}
tablosById = new Map((tablos || []).map((tablo) => [tablo.id, tablo.name]));
}
return c.json({
invites: (pendingInvites || []).map((invite) => ({
id: invite.id,
tablo_id: invite.tablo_id,
tablo_name: tablosById.get(invite.tablo_id) || "Tablo",
created_at: invite.created_at,
})),
});
});
const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const inviteId = Number(c.req.param("inviteId"));
if (!Number.isInteger(inviteId) || inviteId <= 0) {
return c.json({ error: "Invalid invite id" }, 400);
}
const { data: inviteData, error: inviteError } = await supabase
.from("tablo_invites")
.select("id, tablo_id, invited_by, invited_email, is_pending")
.eq("id", inviteId)
.maybeSingle();
if (inviteError) {
return c.json({ error: inviteError.message }, 500);
}
if (!inviteData || !inviteData.is_pending) {
return c.json({ error: "Invite not found or no longer pending" }, 404);
}
if (inviteData.invited_email?.toLowerCase() !== user.email?.toLowerCase()) {
return c.json({ error: "You are not authorized to accept this invite" }, 403);
}
try {
await upsertStreamUserFromProfile(supabase, streamServerClient, user.id);
} catch (error) {
console.error("error upserting joining user to stream", error);
return c.json({ error: "Failed to provision chat user" }, 500);
}
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id: inviteData.tablo_id,
user_id: user.id,
is_admin: false,
is_active: true,
granted_by: inviteData.invited_by,
});
if (tabloAccessError) {
if (tabloAccessError.code !== "23505") {
return c.json({ error: tabloAccessError.message }, 500);
}
}
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", inviteData.id);
try {
await ensureTabloChannelMember(supabase, streamServerClient, inviteData.tablo_id, user.id);
} catch (error) {
console.error("error adding member to channel", error);
return c.json({ error: "Failed to sync chat access for this tablo" }, 500);
}
return c.json({ tablo_id: inviteData.tablo_id });
});
const joinTablo = factory.createHandlers(async (c) => {
const { token } = await c.req.json();
@ -712,6 +867,8 @@ export const getTabloRouter = (config: AppConfig) => {
tabloRouter.post("/create", ...createTablo(middlewareManager));
tabloRouter.patch("/update", ...updateTablo(middlewareManager));
tabloRouter.delete("/delete", ...deleteTablo);
tabloRouter.get("/invites/pending", ...getPendingInvitesForCurrentUser(middlewareManager));
tabloRouter.post("/invites/:inviteId/accept", ...acceptInviteById(middlewareManager));
tabloRouter.post("/invite/:tabloId", ...inviteToTablo(config, middlewareManager));
tabloRouter.delete("/invite/:tabloId/:inviteId", ...cancelPendingInvite(middlewareManager));
tabloRouter.post("/join", ...joinTablo);

View file

@ -1,6 +1,7 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
@ -15,10 +16,48 @@ const factory = createFactory<AuthEnv>();
// Metadata file name for folders
const FOLDERS_METADATA_FILE = ".tablo-folders.json";
const CACHE_TTL_MS = 15_000;
type CacheEntry<T> = {
value: T;
expiresAt: number;
};
const fileNamesCache = new Map<string, CacheEntry<string[]>>();
const foldersCache = new Map<string, CacheEntry<TabloFoldersMetadata>>();
export const clearTabloDataCachesForTests = () => {
fileNamesCache.clear();
foldersCache.clear();
};
// Helper to generate unique folder IDs
const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const getCachedValue = <T>(entry: CacheEntry<T> | undefined): T | null => {
if (!entry) return null;
if (Date.now() >= entry.expiresAt) return null;
return entry.value;
};
const setCacheValue = <T>(map: Map<string, CacheEntry<T>>, key: string, value: T) => {
map.set(key, {
value,
expiresAt: Date.now() + CACHE_TTL_MS,
});
};
const getCachedTabloFileNames = async (s3_client: S3Client, tabloId: string): Promise<string[]> => {
const cached = getCachedValue(fileNamesCache.get(tabloId));
if (cached) {
return cached;
}
const fileNames = (await getTabloFileNames(s3_client, tabloId)) || [];
setCacheValue(fileNamesCache, tabloId, fileNames);
return fileNames;
};
// ============================================
// FILE ENDPOINTS
// ============================================
@ -28,8 +67,8 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) =>
const s3_client = c.get("s3_client");
try {
const fileNames = await getTabloFileNames(s3_client, tabloId);
return c.json({ fileNames: fileNames || [] });
const fileNames = await getCachedTabloFileNames(s3_client, tabloId);
return c.json({ fileNames });
} catch (error) {
console.error("Error fetching tablo files:", error);
return c.json({ error: "Failed to fetch tablo files" }, 500);
@ -57,8 +96,8 @@ const getAllTablosFilenames = factory.createHandlers(async (c) => {
const results = await Promise.all(
tabloIds.map(async (tabloId: string) => {
const fileNames = await getTabloFileNames(s3_client, tabloId);
return { tabloId, fileNames: fileNames ?? [] };
const fileNames = await getCachedTabloFileNames(s3_client, tabloId);
return { tabloId, fileNames };
})
);
@ -108,6 +147,7 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const user = c.get("user");
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
const filePath = c.req.param("path") || c.req.param("fileName");
@ -131,8 +171,12 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
Key: `${tabloId}/${filePath}`,
Body: content,
ContentType: contentType,
Metadata: {
"uploaded-by": user.id,
},
})
);
fileNamesCache.delete(tabloId);
return c.json({
message: "File uploaded successfully",
@ -146,8 +190,10 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
});
const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const user = c.get("user");
const supabase = c.get("supabase");
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
const filePath = c.req.param("path") || c.req.param("fileName");
@ -158,12 +204,58 @@ const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.
const s3_client = c.get("s3_client");
try {
const { data: access, error: accessError } = await supabase
.from("tablo_access")
.select("is_admin")
.eq("tablo_id", tabloId)
.eq("user_id", user.id)
.eq("is_active", true)
.maybeSingle();
if (accessError) {
return c.json({ error: "Failed to verify access rights" }, 500);
}
if (!access) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
if (!access.is_admin) {
try {
const headResponse = await s3_client.send(
new HeadObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
})
);
const uploadedBy =
headResponse.Metadata?.["uploaded-by"] ??
headResponse.Metadata?.uploaded_by ??
null;
if (uploadedBy !== user.id) {
return c.json({ error: "You can only delete files you uploaded" }, 403);
}
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
?.httpStatusCode;
if (statusCode === 404) {
return c.json({ error: "You can only delete files you uploaded" }, 403);
}
console.error("Error checking file ownership:", error);
return c.json({ error: "Failed to verify file ownership" }, 500);
}
}
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
})
);
fileNamesCache.delete(tabloId);
return c.json({
message: "File deleted successfully",
@ -185,6 +277,11 @@ const getFolderMetadata = async (
s3_client: S3Client,
tabloId: string
): Promise<TabloFoldersMetadata> => {
const cached = getCachedValue(foldersCache.get(tabloId));
if (cached) {
return cached;
}
try {
const response = await s3_client.send(
new GetObjectCommand({
@ -195,12 +292,16 @@ const getFolderMetadata = async (
if (response.Body) {
const content = await response.Body.transformToString();
return JSON.parse(content);
const metadata = JSON.parse(content) as TabloFoldersMetadata;
setCacheValue(foldersCache, tabloId, metadata);
return metadata;
}
} catch {
// File doesn't exist, return default
}
return { folders: [], version: 1 };
const emptyMetadata = { folders: [], version: 1 };
setCacheValue(foldersCache, tabloId, emptyMetadata);
return emptyMetadata;
};
// Helper to save folder metadata
@ -217,6 +318,7 @@ const saveFolderMetadata = async (
ContentType: "application/json",
})
);
setCacheValue(foldersCache, tabloId, metadata);
};
// GET /tablo-data/:tabloId/folders - Get all folders for a tablo
@ -368,7 +470,6 @@ export const getTabloDataRouter = () => {
const middlewareManager = MiddlewareManager.getInstance();
tabloDataRouter.use(middlewareManager.auth);
tabloDataRouter.use(middlewareManager.streamChat);
tabloDataRouter.use(middlewareManager.r2);
// All-tablos file listing (must be before /:tabloId routes)

View file

@ -85,7 +85,7 @@ describe("ChannelPreview", () => {
it("highlights active channel", () => {
const { container } = render(<ChannelPreview {...defaultProps} activeChannel={mockChannel} />);
expect(container.querySelector(".bg-blue-50")).toBeInTheDocument();
expect(container.querySelector(".bg-purple-50")).toBeInTheDocument();
});
it("displays latest message preview", () => {
@ -100,6 +100,6 @@ describe("ChannelPreview", () => {
it("shows active indicator for active channel", () => {
const { container } = render(<ChannelPreview {...defaultProps} activeChannel={mockChannel} />);
expect(container.querySelector(".bg-blue-500")).toBeInTheDocument();
expect(container.querySelector(".absolute.left-0.top-0.bottom-0.w-1")).toBeInTheDocument();
});
});

View file

@ -27,24 +27,24 @@ describe("CreateTabloModal", () => {
it("renders without crashing", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByText("Create a new tablo")).toBeInTheDocument();
expect(screen.getByText("Create a new project")).toBeInTheDocument();
});
it("displays name input field", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByPlaceholderText("Enter tablo name")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter project name")).toBeInTheDocument();
});
it("allows typing in name input", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter tablo name") as HTMLInputElement;
const input = screen.getByPlaceholderText("Enter project name") as HTMLInputElement;
fireEvent.change(input, { target: { value: "New Tablo" } });
expect(input.value).toBe("New Tablo");
});
it("calls onCreate when create button is clicked with valid name", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter tablo name");
const input = screen.getByPlaceholderText("Enter project name");
fireEvent.change(input, { target: { value: "New Tablo" } });
const createButton = screen.getByText("Create");
@ -85,7 +85,7 @@ describe("CreateTabloModal", () => {
it("resets form after successful creation", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter tablo name") as HTMLInputElement;
const input = screen.getByPlaceholderText("Enter project name") as HTMLInputElement;
fireEvent.change(input, { target: { value: "New Tablo" } });
const createButton = screen.getByText("Create");
@ -96,7 +96,7 @@ describe("CreateTabloModal", () => {
it("disables create button when in image mode", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter tablo name");
const input = screen.getByPlaceholderText("Enter project name");
fireEvent.change(input, { target: { value: "New Tablo" } });
// Switch to image mode

View file

@ -38,7 +38,7 @@ describe("DeleteTabloModal", () => {
isDeleting={false}
/>
);
expect(screen.getByText("Delete tablo")).toBeInTheDocument();
expect(screen.getByText("Delete project")).toBeInTheDocument();
});
it("returns null when tablo is null", () => {
@ -142,7 +142,7 @@ describe("DeleteTabloModal", () => {
/>
);
expect(
screen.getByText("All data associated with this tablo will be permanently lost.")
screen.getByText("All data associated with this project will be permanently lost.")
).toBeInTheDocument();
});
});

View file

@ -40,7 +40,7 @@ describe("EventModal", () => {
it("displays form fields", () => {
renderWithProviders(<EventModal mode="create" />);
expect(screen.getByText("Title *")).toBeInTheDocument();
expect(screen.getByText("Tablo *")).toBeInTheDocument();
expect(screen.getByText("Project *")).toBeInTheDocument();
expect(screen.getByText("Date *")).toBeInTheDocument();
expect(screen.getByText("Start *")).toBeInTheDocument();
expect(screen.getByText("End")).toBeInTheDocument();

View file

@ -118,7 +118,7 @@ describe("EventTypeCard", () => {
const { container } = renderWithProviders(
<EventTypeCard eventType={inactiveEventType} handleEditEventType={handleEditEventType} />
);
const card = container.querySelector(".opacity-60");
const card = container.querySelector(".opacity-70");
expect(card).toBeInTheDocument();
});
});

View file

@ -61,7 +61,11 @@ export function EventTypeCard({
return (
<Card
key={eventType.id}
className={`${eventType.isActive ? "opacity-100" : "opacity-60"} transition-shadow hover:shadow-lg`}
className={`${
eventType.isActive
? "opacity-100 border-[#804EEC]/30 bg-white dark:bg-[#804EEC]/5"
: "opacity-70 border-[#804EEC]/15"
} transition-all hover:shadow-[0_10px_25px_rgba(128,78,236,0.18)]`}
>
<CardHeader className="min-h-[80px]">
<CardTitle className="text-lg">{eventType.name}</CardTitle>
@ -71,6 +75,7 @@ export function EventTypeCard({
variant="ghost"
size="icon"
onClick={() => setIsEmbedModalOpen(true)}
className="text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("eventTypeCard.aria.settings")}
>
<SettingsIcon className="w-4 h-4" />
@ -81,6 +86,7 @@ export function EventTypeCard({
onClick={() =>
window.open(getPublicLink(eventType.standardName ?? null, "normal"), "_blank")
}
className="text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("eventTypeCard.aria.preview")}
>
<ExternalLinkIcon className="w-4 h-4" />
@ -89,6 +95,7 @@ export function EventTypeCard({
variant="ghost"
size="icon"
onClick={() => handleEditEventType(eventType.id, eventType as EventTypeConfig)}
className="text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("eventTypeCard.aria.edit")}
>
<EditIcon className="w-4 h-4" />
@ -146,7 +153,7 @@ export function EventTypeCard({
</div>
</CardContent>
<CardFooter className="justify-between border-t">
<CardFooter className="justify-between border-t border-[#804EEC]/20">
<span className="text-muted-foreground">{t("eventTypeCard.status")}</span>
<Button
variant={eventType.isActive ? "default" : "outline"}
@ -157,7 +164,11 @@ export function EventTypeCard({
isActive: !eventType.isActive,
})
}
className="text-sm"
className={`text-sm ${
eventType.isActive
? "bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
: "border-[#804EEC]/40 text-[#804EEC] hover:bg-[#804EEC]/10 hover:text-[#6f3fd4]"
}`}
>
{eventType.isActive ? <CheckIcon /> : <XIcon />}
{eventType.isActive ? t("eventTypeCard.active") : t("eventTypeCard.inactive")}

View file

@ -253,6 +253,7 @@ export function EventTypeModal({
variant="default"
onClick={handleSaveEventType}
disabled={!formData.name?.trim() || !formData.duration}
className="bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
{editingEventType
? t("eventTypeModal.buttons.edit")

View file

@ -65,7 +65,7 @@ describe("ImportICSModal", () => {
it("displays create new tablo checkbox", () => {
renderWithProviders(<ImportICSModal onClose={mockOnClose} />);
expect(screen.getByText("Create a new tablo")).toBeInTheDocument();
expect(screen.getByText("Create a new project")).toBeInTheDocument();
});
it("disables import button initially", () => {

View file

@ -1,23 +1,29 @@
import { fireEvent, screen } from "@testing-library/react";
import { Layout } from "@ui/components/Layout";
import { beforeEach } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
describe("Layout", () => {
beforeEach(() => {
localStorage.setItem("xtablo-onboarding-completed", "true");
});
it("renders the layout with children", () => {
renderWithProviders(<Layout />);
const { container } = renderWithProviders(<Layout />);
// Check if the mobile menu button is present
expect(screen.getByRole("button", { name: /menu/i })).toBeInTheDocument();
expect(container.querySelector("button.md\\:hidden")).toBeInTheDocument();
});
it("has a menu button that can be clicked", () => {
renderWithProviders(<Layout />);
const { container } = renderWithProviders(<Layout />);
// Get the menu button
const menuButton = screen.getByRole("button", { name: /menu/i });
const menuButton = container.querySelector("button.md\\:hidden");
expect(menuButton).toBeInTheDocument();
// Click the menu button - should not throw
fireEvent.click(menuButton);
fireEvent.click(menuButton!);
expect(menuButton).toBeInTheDocument();
});

View file

@ -13,7 +13,6 @@ import {
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { cva, type VariantProps } from "class-variance-authority";
import {
CalendarCheckIcon,
CalendarIcon,
Circle,
Compass,
@ -203,14 +202,6 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
/>
</RouterLink>
<RouterLink to="/events">
<MenuDropdownItem
icon={<CalendarCheckIcon className="w-5 h-5" aria-hidden="true" />}
label={t("myEvents")}
variant="default"
/>
</RouterLink>
<RouterLink to="/availabilities">
<MenuDropdownItem
icon={<CalendarIcon className="w-5 h-5" aria-hidden="true" />}

View file

@ -104,16 +104,14 @@ const FileIcon = ({ type }: { type: "image" | "pdf" | "text" | "other" }) => {
// File Item Component
const FileItem = ({
displayName,
isAdmin,
isReadOnly,
canDelete,
onDownload,
onDelete,
isDownloading,
isDeleting,
}: {
displayName: string;
isAdmin: boolean;
isReadOnly: boolean;
canDelete: boolean;
onDownload: () => void;
onDelete: () => void;
isDownloading: boolean;
@ -159,7 +157,7 @@ const FileItem = ({
<DownloadIcon className="w-4 h-4" />
)}
</Button>
{isAdmin && !isReadOnly && (
{canDelete && (
<Button
size="sm"
variant="ghost"
@ -435,8 +433,7 @@ const FolderSection = ({
<FileItem
key={fileName}
displayName={getFileNameWithoutFolder(fileName)}
isAdmin={isAdmin}
isReadOnly={isReadOnly}
canDelete={true}
onDownload={() => onDownloadFile(fileName)}
onDelete={() => onDeleteFile(fileName)}
isDownloading={downloadingFile === fileName}
@ -458,8 +455,12 @@ const FolderSection = ({
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
const currentUser = useUser();
const { data: fileData, isLoading: filesLoading } = useTabloFileNames(tablo.id);
const { data: foldersData, isLoading: foldersLoading } = useTabloFolders(tablo.id);
const { data: fileData, isLoading: filesLoading, error: filesError } = useTabloFileNames(tablo.id);
const {
data: foldersData,
isLoading: foldersLoading,
error: foldersError,
} = useTabloFolders(tablo.id);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
@ -479,6 +480,8 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
const updateFolder = useUpdateTabloFolder();
const deleteFolder = useDeleteTabloFolder();
const isReadOnly = useIsReadOnlyUser();
const folders = foldersData?.folders || [];
const folderIds = useMemo(() => new Set(folders.map((folder) => folder.id)), [folders]);
// Organize files by folder
const { filesInFolders, unorganizedFiles } = useMemo(() => {
@ -494,7 +497,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
if (fileName.startsWith(".")) continue;
const folderId = extractFolderIdFromFileName(fileName);
if (folderId) {
if (folderId && folderIds.has(folderId)) {
const existing = filesInFolders.get(folderId) || [];
existing.push(fileName);
filesInFolders.set(folderId, existing);
@ -504,9 +507,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
}
return { filesInFolders, unorganizedFiles };
}, [fileData?.fileNames]);
const folders = foldersData?.folders || [];
}, [fileData?.fileNames, folderIds]);
const toggleFolder = (folderId: string) => {
setOpenFolders((prev) => {
@ -733,6 +734,13 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
</button>
</div>
)}
{(filesError || foldersError) && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<span className="text-red-700 dark:text-red-300 text-sm">
Impossible de charger les fichiers pour ce tablo.
</span>
</div>
)}
{/* Create Folder Button - Admin Only */}
{isAdmin && !isReadOnly && (
@ -921,9 +929,8 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
{unorganizedFiles.map((fileName) => (
<FileItem
key={fileName}
displayName={fileName}
isAdmin={isAdmin}
isReadOnly={isReadOnly}
displayName={getFileNameWithoutFolder(fileName)}
canDelete={true}
onDownload={() => handleDownloadFile(fileName)}
onDelete={() => handleDeleteFile(fileName)}
isDownloading={downloadingFile === fileName}

View file

@ -25,6 +25,10 @@ vi.mock("../hooks/tablo_invites", () => ({
usePendingTabloInvitesByTablo: () => ({
data: [],
}),
useCancelTabloInvite: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
vi.mock("../hooks/invite", () => ({

View file

@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloOverviewSection } from "./TabloOverviewSection";
const mockUseTablo = vi.fn();
const mockUseTabloEtapes = vi.fn();
const mockUseTasksByTablo = vi.fn();
const createEtapeMock = { mutateAsync: vi.fn(), isPending: false };
@ -11,10 +10,6 @@ const updateEtapeMock = { mutateAsync: vi.fn(), isPending: false };
const deleteEtapeMock = { mutateAsync: vi.fn(), isPending: false };
const reorderEtapesMock = { mutateAsync: vi.fn(), isPending: false };
vi.mock("../hooks/tablos", () => ({
useTablo: (tabloId: string) => mockUseTablo(tabloId),
}));
vi.mock("../hooks/tasks", () => ({
useTabloEtapes: (tabloId: string) => mockUseTabloEtapes(tabloId),
useTasksByTablo: (tabloId: string) => mockUseTasksByTablo(tabloId),
@ -28,6 +23,10 @@ vi.mock("./TabloFilesSection", () => ({
TabloFilesSection: () => <div data-testid="tablo-files-section" />,
}));
vi.mock("./TabloHeaderActions", () => ({
TabloHeaderActions: () => <div data-testid="tablo-header-actions" />,
}));
const mockTablo = {
id: "tablo-1",
name: "Projet Alpha",
@ -71,7 +70,6 @@ beforeEach(() => {
updateEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
deleteEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
reorderEtapesMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
mockUseTablo.mockReturnValue({ data: { owner_id: "123" } });
});
describe("TabloOverviewSection", () => {
@ -83,8 +81,6 @@ describe("TabloOverviewSection", () => {
});
it("hides management actions for non owners", () => {
mockUseTablo.mockReturnValue({ data: { owner_id: "another-user" } });
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin={false} />, {
language: "fr",
});
@ -92,7 +88,7 @@ describe("TabloOverviewSection", () => {
expect(screen.queryByPlaceholderText("Nom de l'Étape")).not.toBeInTheDocument();
expect(
screen.getByText(
"Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape."
"Seul le propriétaire du projet peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape."
)
).toBeInTheDocument();
});

View file

@ -2,7 +2,6 @@ import { toast } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import { Progress } from "@xtablo/ui/components/progress";
import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
import { ArrowDown, ArrowUp, Check, Edit2, Loader2, Plus, Trash2, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
@ -15,6 +14,8 @@ import {
useTasksByTablo,
useUpdateEtape,
} from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
import { getEtapeProgressStats } from "../utils/etapeProgress";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloOverviewSectionProps {
@ -24,8 +25,9 @@ interface TabloOverviewSectionProps {
export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionProps) => {
const { t } = useTranslation();
const currentUser = useUser();
const { data: etapes = [], isLoading: isLoadingEtapes } = useTabloEtapes(tablo.id);
const { data: tasks = [] } = useTasksByTablo(tablo.id);
const { data: tasks = [] } = useTasksByTablo(tablo.id, { assigneeId: currentUser.id });
const createEtape = useCreateEtape();
const updateEtape = useUpdateEtape();
const deleteEtape = useDeleteEtape();
@ -39,13 +41,10 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
const sortedEtapes = useMemo(() => [...etapes].sort((a, b) => a.position - b.position), [etapes]);
// Calculate overall tablo progress
// Calculate overall tablo progress from etape statuses
const overallProgress = useMemo(() => {
const totalTasks = tasks.length;
const doneTasks = tasks.filter((task) => task.status === "done").length;
const percentage = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
return { total: totalTasks, done: doneTasks, percentage };
}, [tasks]);
return getEtapeProgressStats(etapes);
}, [etapes]);
// Calculate task counts per etape
const getEtapeTaskCounts = useCallback(
@ -314,9 +313,32 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
})}
</TypographyMuted>
</div>
<div className="text-2xl font-bold text-primary">{overallProgress.percentage}%</div>
<div className="text-2xl font-bold text-primary">{overallProgress.donePercentage}%</div>
</div>
<div className="relative h-3 w-full overflow-hidden rounded-full bg-muted">
<div
className="absolute inset-y-0 left-0 bg-blue-500/40 transition-all"
style={{ width: `${overallProgress.startedPercentage}%` }}
/>
<div
className="absolute inset-y-0 left-0 bg-green-500 transition-all"
style={{ width: `${overallProgress.donePercentage}%` }}
/>
</div>
<div className="mt-2 flex items-center gap-4 text-xs">
<span className="text-blue-600 dark:text-blue-400">
{t("tablo:overview.inProgressSummary", {
started: overallProgress.started,
total: overallProgress.total,
})}
</span>
<span className="text-green-600 dark:text-green-400">
{t("tablo:overview.progressSummary", {
done: overallProgress.done,
total: overallProgress.total,
})}
</span>
</div>
<Progress value={overallProgress.done} max={overallProgress.total} className="h-3" />
</div>
)}

View file

@ -32,6 +32,32 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
const { mutate: updateTaskPositions } = useUpdateTaskPositions();
const { mutate: createTask } = useCreateTask();
const memberById = useMemo(
() => new Map(members.map((member) => [member.id, member])),
[members]
);
const tasksWithAssigneeFallback = useMemo(
() =>
(tasks ?? []).map((task) => {
if (!task.assignee_id) {
return task;
}
const assignee = memberById.get(task.assignee_id);
if (!assignee) {
return task;
}
return {
...task,
assignee_name: task.assignee_name ?? assignee.name,
assignee_avatar: task.assignee_avatar ?? assignee.avatar_url,
} satisfies KanbanTask;
}),
[memberById, tasks]
);
const etapeTitleMap = useMemo(
() =>
etapes.reduce<Record<string, string>>((map, etape) => {
@ -43,8 +69,8 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
// Check for tasks without parent (orphaned tasks)
const orphanedTasks = useMemo(() => {
return tasks?.filter((task) => !task.parent_task_id) || [];
}, [tasks]);
return tasksWithAssigneeFallback.filter((task) => !task.parent_task_id);
}, [tasksWithAssigneeFallback]);
// Helper functions defined before use
const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => {
@ -82,8 +108,8 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
}, []);
useEffect(() => {
setColumns(initializeColumns(tasks ?? []));
}, [initializeColumns, tasks]);
setColumns(initializeColumns(tasksWithAssigneeFallback));
}, [initializeColumns, tasksWithAssigneeFallback]);
const handleAddTask = (status: TaskStatus) => {
setSelectedTask(null);

View file

@ -34,12 +34,17 @@ import {
} from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useLogout } from "../hooks/auth";
import {
useNotifications,
useNotificationsSubscription,
} from "../hooks/notifications";
import {
useAcceptTabloInvite,
useCancelTabloInvite,
useReceivedTabloInvites,
} from "../hooks/tablo_invites";
import { useUser } from "../providers/UserStoreProvider";
type Notification = Database["public"]["Tables"]["notifications"]["Row"];
@ -259,6 +264,81 @@ function NotificationDropdown() {
);
}
function TabloInvitesDropdown() {
const { t } = useTranslation("navigation");
const navigate = useNavigate();
const { data: invites = [] } = useReceivedTabloInvites();
const { mutate: acceptInvite, isPending: isAccepting } = useAcceptTabloInvite();
const { mutate: dismissInvite, isPending: isDismissing } = useCancelTabloInvite();
const isActionPending = isAccepting || isDismissing;
if (invites.length === 0) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-10 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] text-[#0C111D] dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 px-3"
aria-label={t("invites.title")}
>
{t("invites.title")}
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-[#804EEC] text-white text-[10px] font-semibold leading-none">
{invites.length}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-80 bg-white border border-[#EAECF0] p-1 rounded-lg text-gray-900 shadow-lg"
side="bottom"
align="end"
sideOffset={8}
>
<div className="px-4 py-3 border-b border-gray-100">
<TypographySmall className="font-semibold text-gray-900">{t("invites.title")}</TypographySmall>
</div>
<div className="max-h-[340px] overflow-y-auto divide-y divide-gray-100">
{invites.map((invite) => (
<div key={invite.id} className="px-4 py-3">
<TypographySmall className="font-medium text-gray-900">{invite.tablo_name}</TypographySmall>
<div className="mt-3 flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={isActionPending}
onClick={() => dismissInvite({ tabloId: invite.tablo_id, inviteId: invite.id })}
>
{t("invites.dismiss")}
</Button>
<Button
size="sm"
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
disabled={isActionPending}
onClick={() => {
acceptInvite(
{ inviteId: invite.id },
{
onSuccess: ({ tablo_id }) => {
navigate(`/tablos/${tablo_id}`);
},
}
);
}}
>
{t("invites.accept")}
</Button>
</div>
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProfileDropdown() {
const { t } = useTranslation("navigation");
const user = useUser();
@ -314,13 +394,6 @@ function ProfileDropdown() {
</DropdownMenuItem>
</Link>
<Link to="/events">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
<CalendarCheckIcon className="w-4 h-4" />
{t("myEvents")}
</DropdownMenuItem>
</Link>
<Link to="/availabilities">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
<CalendarIcon className="w-4 h-4" />
@ -401,6 +474,7 @@ export function TopBar() {
</Link>
</div>
)}
<TabloInvitesDropdown />
<NotificationDropdown />
<ProfileDropdown />
</div>

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Channel, StreamChat } from "stream-chat";
import { useUser } from "../providers/UserStoreProvider";
export const useChannelFromUrl = (client: StreamChat) => {
const [channel, setChannel] = useState<Channel | null>(null);
@ -16,3 +17,93 @@ export const useChannelFromUrl = (client: StreamChat) => {
}, [channelId, client]);
return { channel, isChannelInUrl: !!channelId };
};
export const useTabloDiscussionUnread = (tabloId?: string) => {
const user = useUser();
const [hasUnread, setHasUnread] = useState(false);
useEffect(() => {
if (!tabloId || !user.id || !user.streamToken) {
setHasUnread(false);
return;
}
const apiKey = import.meta.env.VITE_STREAM_CHAT_API_KEY as string;
const client = StreamChat.getInstance(apiKey);
let isMounted = true;
let unsubscribe: (() => void) | undefined;
const syncUnread = (channel: Channel) => {
if (!isMounted) return;
setHasUnread(channel.countUnread() > 0);
};
const init = async () => {
try {
if (!client.userID) {
await client.connectUser(
{
id: user.id,
name: user.name ?? "",
},
user.streamToken
);
} else if (client.userID !== user.id) {
await client.disconnectUser();
await client.connectUser(
{
id: user.id,
name: user.name ?? "",
},
user.streamToken
);
}
const channels = await client.queryChannels(
{
type: "messaging",
id: { $eq: tabloId },
members: { $in: [user.id] },
},
{ last_message_at: -1 },
{ watch: true, state: true, presence: false, limit: 1 }
);
const channel = channels[0];
if (!channel) {
setHasUnread(false);
return;
}
syncUnread(channel);
const subscriptions = [
channel.on("message.new", () => syncUnread(channel)),
channel.on("message.read", () => syncUnread(channel)),
channel.on("notification.mark_read", () => syncUnread(channel)),
channel.on("notification.message_new", () => syncUnread(channel)),
];
unsubscribe = () => {
subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
};
} catch (error) {
console.error("Error loading tablo unread discussion state:", error);
if (isMounted) {
setHasUnread(false);
}
}
};
void init();
return () => {
isMounted = false;
unsubscribe?.();
};
}, [tabloId, user.id, user.name, user.streamToken]);
return { hasUnread };
};

View file

@ -247,7 +247,9 @@ export function useDeleteTabloFile() {
mutationFn: async ({ tabloId, fileName }) => {
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/file/${fileName}`);
if (response.status !== 200) {
throw new Error("Failed to delete file");
const errorMessage =
typeof response.data?.error === "string" ? response.data.error : "Failed to delete file";
throw new Error(errorMessage);
}
return response.data;
},

View file

@ -6,6 +6,12 @@ import { useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
type TabloInvite = Database["public"]["Tables"]["tablo_invites"]["Row"];
type PendingReceivedInvite = {
id: number;
tablo_id: string;
tablo_name: string;
created_at: string;
};
// Fetch all pending invites created by the current user
// export const usePendingTabloInvites = () => {
@ -62,7 +68,9 @@ export const useCancelTabloInvite = () => {
},
onSuccess: (_data, { tabloId }) => {
queryClient.invalidateQueries({ queryKey: ["tablo-invites", tabloId] });
queryClient.invalidateQueries({ queryKey: ["tablo-invites", "received"] });
queryClient.invalidateQueries({ queryKey: ["tablo-members", tabloId] });
queryClient.invalidateQueries({ queryKey: ["notifications"] });
toast.add(
{
title: "Invitation retirée",
@ -85,3 +93,56 @@ export const useCancelTabloInvite = () => {
},
});
};
export const useReceivedTabloInvites = () => {
const api = useAuthedApi();
return useQuery({
queryKey: ["tablo-invites", "received"],
queryFn: async () => {
const { data } = await api.get<{ invites: PendingReceivedInvite[] }>(
"/api/v1/tablos/invites/pending"
);
return data.invites;
},
refetchInterval: 30000,
});
};
export const useAcceptTabloInvite = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ inviteId }: { inviteId: number }) => {
const { data } = await api.post<{ tablo_id: string }>(
`/api/v1/tablos/invites/${inviteId}/accept`
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tablo-invites", "received"] });
queryClient.invalidateQueries({ queryKey: ["tablos"] });
queryClient.invalidateQueries({ queryKey: ["notifications"] });
toast.add(
{
title: "Invitation acceptée",
description: "Vous avez rejoint le tablo",
type: "success",
},
{ timeout: 3000 }
);
},
onError: (error) => {
console.error("Error accepting invite:", error);
toast.add(
{
title: "Erreur",
description: "Impossible d'accepter l'invitation",
type: "error",
},
{ timeout: 5000 }
);
},
});
};

View file

@ -92,17 +92,28 @@ export const useAllTasks = () => {
};
// Fetch all tasks for a specific tablo
export const useTasksByTablo = (tabloId: string | undefined) => {
export const useTasksByTablo = (
tabloId: string | undefined,
options?: { assigneeId?: string }
) => {
const assigneeId = options?.assigneeId;
return useQuery({
queryKey: ["tasks", "tablo", tabloId],
queryKey: ["tasks", "tablo", tabloId, assigneeId ?? "all-assignees"],
queryFn: async () => {
const { data, error } = await supabase
let query = supabase
.from("tasks_with_assignee")
.select("*")
.eq("tablo_id", tabloId)
.eq("is_parent", false)
.order("position", { ascending: true });
if (assigneeId) {
query = query.eq("assignee_id", assigneeId);
}
const { data, error } = await query;
if (error) throw error;
return data as KanbanTask[];
},

View file

@ -4,7 +4,8 @@
"tabs": {
"availabilities": "Availabilities",
"exceptions": "Exceptions",
"visualization": "Visualization"
"visualization": "Visualization",
"callTypes": "Call types"
},
"actions": {
"save": "Save",
@ -50,6 +51,14 @@
"exceptionDate": "Exception date"
}
},
"callTypes": {
"title": "Call Types",
"description": "Create and manage your call types for bookings.",
"create": "New Call Type",
"empty": "No call types configured yet.",
"readOnly": "You are in read-only mode. You cannot create call types.",
"required": "Please fill all required fields."
},
"copy": {
"title": "Copy {{day}} hours",
"description": "Select the days you want to copy these hours to",

View file

@ -26,5 +26,10 @@
"noNotifications": "No new notifications",
"allCaughtUp": "You're all caught up!",
"viewAll": "View all notifications"
},
"invites": {
"title": "Invites",
"dismiss": "Dismiss",
"accept": "Accept"
}
}

View file

@ -3,7 +3,8 @@
"title": "Overview",
"description": "Configure the Stages of the project to clarify the major phases of your project.",
"overallProgress": "Overall Progress",
"progressSummary": "{{done}} of {{total}} task(s) completed"
"progressSummary": "{{done}} of {{total}} stage(s) completed",
"inProgressSummary": "{{started}} of {{total}} stage(s) at least in progress"
},
"etape": {
"nameRequired": "The Stage name is required",

View file

@ -4,7 +4,8 @@
"tabs": {
"availabilities": "Disponibilités",
"exceptions": "Exceptions",
"visualization": "Visualisation"
"visualization": "Visualisation",
"callTypes": "Types d'appel"
},
"actions": {
"save": "Enregistrer",
@ -50,6 +51,14 @@
"exceptionDate": "Date de l'exception"
}
},
"callTypes": {
"title": "Types d'appel",
"description": "Créez et gérez vos types d'appel pour vos réservations.",
"create": "Nouveau Type d'appel",
"empty": "Aucun type d'appel configuré pour le moment.",
"readOnly": "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de type d'appel.",
"required": "Veuillez remplir tous les champs obligatoires."
},
"copy": {
"title": "Copier les horaires de {{day}}",
"description": "Sélectionnez les jours vers lesquels vous souhaitez copier ces horaires",

View file

@ -26,5 +26,10 @@
"noNotifications": "Aucune nouvelle notification",
"allCaughtUp": "Vous êtes à jour !",
"viewAll": "Voir toutes les notifications"
},
"invites": {
"title": "Invitations",
"dismiss": "Ignorer",
"accept": "Accepter"
}
}

View file

@ -3,7 +3,8 @@
"title": "Vue d'ensemble",
"description": "Configurez les Étapes du projet pour clarifier les grandes phases de votre projet.",
"overallProgress": "Progression globale",
"progressSummary": "{{done}} sur {{total}} tâche(s) terminée(s)"
"progressSummary": "{{done}} sur {{total}} étape(s) terminée(s)",
"inProgressSummary": "{{started}} sur {{total}} étape(s) au moins en cours"
},
"etape": {
"nameRequired": "Le nom de l'Étape est requis",

View file

@ -25,13 +25,17 @@ import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/componen
import { Plus as PlusIcon, SaveIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeModal } from "../components/EventTypeModal";
import { ExceptionModal } from "../components/ExceptionModal";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import {
DEFAULT_AVAILABILITIES,
Exception,
useAvailabilities,
WeeklyAvailability,
} from "../hooks/availabilities";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
const DAYS_OF_WEEK_DISPLAY = [
@ -51,6 +55,7 @@ interface TimeRange {
export function AvailabilitiesPage() {
const { t } = useTranslation(["availabilities", "common"]);
const isReadOnly = useIsReadOnlyUser();
const {
updateAvailabilities,
draftAvailabilities,
@ -59,6 +64,7 @@ export function AvailabilitiesPage() {
deleteException,
isModified,
} = useAvailabilities();
const { eventTypes: eventTypesData, addEventType, updateEventType } = useEventTypes();
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [sourceDayData, setSourceDayData] = useState<{
@ -68,6 +74,20 @@ export function AvailabilitiesPage() {
} | null>(null);
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [exceptionModalOpen, setExceptionModalOpen] = useState(false);
const [isEventTypeModalOpen, setIsEventTypeModalOpen] = useState(false);
const [editingEventType, setEditingEventType] = useState<
(EventTypeConfig & { id: string }) | null
>(null);
const [eventTypeFormData, setEventTypeFormData] = useState<
Partial<EventTypeConfig> & { isActive?: boolean }
>({
name: "",
description: "",
duration: 60,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
const handleCopyToOtherDays = (sourceDay: number, enabled: boolean, timeRanges: TimeRange[]) => {
setSourceDayData({ day: sourceDay, enabled, timeRanges });
@ -98,6 +118,58 @@ export function AvailabilitiesPage() {
});
};
const handleCreateEventType = () => {
if (isReadOnly) {
toast.add({
title: t("common:error"),
description: t("availabilities:callTypes.readOnly"),
type: "error",
});
return;
}
setEditingEventType(null);
setEventTypeFormData({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
setIsEventTypeModalOpen(true);
};
const handleEditEventType = (id: string, eventType: EventTypeConfig) => {
setEditingEventType({ id, ...eventType });
setEventTypeFormData(eventType);
setIsEventTypeModalOpen(true);
};
const handleSaveEventType = () => {
if (!eventTypeFormData.name) {
toast.add({
title: t("common:error"),
description: t("availabilities:callTypes.required"),
type: "error",
});
return;
}
if (editingEventType) {
updateEventType({
id: editingEventType.id,
eventType: eventTypeFormData as EventTypeConfig,
});
} else {
addEventType({ eventType: eventTypeFormData as EventTypeConfig });
}
setIsEventTypeModalOpen(false);
setEditingEventType(null);
};
// Filter exceptions to only show upcoming dates (today and future)
const today = new Date();
today.setHours(0, 0, 0, 0);
@ -111,7 +183,7 @@ export function AvailabilitiesPage() {
return (
<div className="min-h-screen">
<header className="bg-card shadow-sm border-b border-border">
<header className="bg-card shadow-sm border-b border-[#804EEC]/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div>
<TypographyH3>{t("availabilities:title")}</TypographyH3>
@ -122,14 +194,31 @@ export function AvailabilitiesPage() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Tabs defaultValue="availabilities" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="availabilities">
<TabsList className="mb-6 border border-[#804EEC]/25 bg-[#804EEC]/5 p-1">
<TabsTrigger
value="availabilities"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.availabilities")}
</TabsTrigger>
<TabsTrigger value="exceptions">{t("availabilities:tabs.exceptions")}</TabsTrigger>
<TabsTrigger value="visualisation">
<TabsTrigger
value="exceptions"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.exceptions")}
</TabsTrigger>
<TabsTrigger
value="visualisation"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.visualization")}
</TabsTrigger>
<TabsTrigger
value="call-types"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.callTypes")}
</TabsTrigger>
</TabsList>
<TabsContent value="availabilities" className="space-y-4">
@ -138,7 +227,7 @@ export function AvailabilitiesPage() {
<Button
size="sm"
variant="default"
className="[--btn-bg:var(--color-green-800)]"
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: draftAvailabilities,
@ -149,20 +238,20 @@ export function AvailabilitiesPage() {
<SaveIcon /> {t("availabilities:actions.save")}
</Button>
)}
<Button
size="sm"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: DEFAULT_AVAILABILITIES,
});
}}
className="py-1"
>
{t("availabilities:actions.businessHours")}
</Button>
<Button
size="sm"
variant="outline"
<Button
size="sm"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: DEFAULT_AVAILABILITIES,
});
}}
className="py-1 border border-[#804EEC]/35 text-[#804EEC] bg-white hover:bg-[#804EEC]/10"
>
{t("availabilities:actions.businessHours")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const newAvailabilities: WeeklyAvailability = {};
DAYS_OF_WEEK.forEach((day) => {
@ -175,14 +264,14 @@ export function AvailabilitiesPage() {
updatedAvailabilities: newAvailabilities,
});
}}
className="py-1"
className="py-1 border-[#804EEC]/35 text-[#804EEC] hover:bg-[#804EEC]/10 hover:text-[#6f3fd4]"
>
{t("availabilities:actions.disableAll")}
</Button>
</div>
<div className="flex items-start">
<div className="flex-1 pr-6 border-r border-gray-200 dark:border-gray-700">
<div className="flex-1 pr-6 border-r border-[#804EEC]/20">
<div className="grid grid-cols-2 gap-4">
{DAYS_OF_WEEK.map((day) => (
<AvailabilityCard
@ -225,7 +314,7 @@ export function AvailabilitiesPage() {
</Text>
</div>
<Card className="bg-muted/30">
<Card className="bg-muted/30 border-[#804EEC]/20">
<CardContent className="px-4">
<Strong className="block mb-2">{t("availabilities:timezone.your")}</Strong>
<Text className="text-gray-500">
@ -241,7 +330,7 @@ export function AvailabilitiesPage() {
</CardContent>
</Card>
<Card className="bg-muted/30">
<Card className="bg-muted/30 border-[#804EEC]/20">
<CardContent className="px-4">
<Strong className="block mb-2">Information</Strong>
<Text className="text-gray-500 text-sm">
@ -270,7 +359,12 @@ export function AvailabilitiesPage() {
</Text>
</div>
{upcomingExceptions.length > 0 && (
<Button variant="default" size="lg" onClick={() => setExceptionModalOpen(true)}>
<Button
variant="default"
size="lg"
onClick={() => setExceptionModalOpen(true)}
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
>
<PlusIcon /> {t("availabilities:actions.addException")}
</Button>
)}
@ -285,7 +379,12 @@ export function AvailabilitiesPage() {
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="default" size="lg" onClick={() => setExceptionModalOpen(true)}>
<Button
variant="default"
size="lg"
onClick={() => setExceptionModalOpen(true)}
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
>
<PlusIcon /> {t("availabilities:actions.addException")}
</Button>
</EmptyContent>
@ -295,7 +394,7 @@ export function AvailabilitiesPage() {
{upcomingExceptions.map(({ exception, originalIndex }) => (
<div
key={`${exception.date}-${originalIndex}`}
className="bg-white dark:bg-gray-700/40 rounded-lg shadow-sm dark:shadow-gray-900/20 p-4 border border-gray-200 dark:border-gray-600/50"
className="bg-white dark:bg-gray-700/40 rounded-lg shadow-sm dark:shadow-gray-900/20 p-4 border border-[#804EEC]/20"
>
<div className="flex justify-between items-start">
<div className="flex-1">
@ -375,6 +474,47 @@ export function AvailabilitiesPage() {
</div>
)}
</TabsContent>
<TabsContent value="call-types" className="space-y-6">
<section className="rounded-2xl border border-[#804EEC]/25 bg-card p-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-xl font-semibold">
{t("availabilities:callTypes.title")}
</h3>
<Text className="text-muted-foreground">
{t("availabilities:callTypes.description")}
</Text>
</div>
<Button
onClick={handleCreateEventType}
disabled={isReadOnly}
className="bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
<PlusIcon className="w-4 h-4 mr-2" />
{t("availabilities:callTypes.create")}
</Button>
</div>
{eventTypesData.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#804EEC]/35 bg-white/70 dark:bg-[#804EEC]/10 p-8 text-center">
<Text className="text-[#5c3aa9] dark:text-[#CDB8FF]">
{t("availabilities:callTypes.empty")}
</Text>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{eventTypesData.map((eventType) => (
<EventTypeCard
key={eventType.id}
eventType={eventType}
handleEditEventType={handleEditEventType}
/>
))}
</div>
)}
</section>
</TabsContent>
</Tabs>
</main>
@ -417,6 +557,7 @@ export function AvailabilitiesPage() {
variant="default"
disabled={selectedDays.length === 0}
onClick={applyCopyToSelectedDays}
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
>
{t("availabilities:copy.copyTo", { count: selectedDays.length })}
</Button>
@ -424,6 +565,15 @@ export function AvailabilitiesPage() {
</DialogContent>
</Dialog>
<EventTypeModal
isModalOpen={isEventTypeModalOpen}
setIsModalOpen={setIsEventTypeModalOpen}
editingEventType={editingEventType}
formData={eventTypeFormData as EventTypeConfig}
setFormData={setEventTypeFormData}
handleSaveEventType={handleSaveEventType}
/>
<ExceptionModal
isOpen={exceptionModalOpen}
onClose={() => setExceptionModalOpen(false)}

View file

@ -1,5 +1,4 @@
import { EventDetailsModal } from "@ui/components/EventDetailsModal";
import { EventTypeModal } from "@ui/components/EventTypeModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { getTextColorFromTabloColor, toast } from "@xtablo/shared";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
@ -13,21 +12,17 @@ import {
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@xtablo/ui/components/tabs";
import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
PlusIcon,
SearchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, useNavigate } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import { useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
@ -42,7 +37,6 @@ export function EventsPage() {
const navigate = useNavigate();
const { t } = useTranslation(["pages", "common"]);
const isReadOnly = useIsReadOnlyUser();
const [activeTab, setActiveTab] = useState<"events" | "event-types">("events");
const statusOptions: BookingStatusOption[] = [
{ id: "upcoming", name: t("pages:events.filters.upcoming") },
@ -58,29 +52,12 @@ export function EventsPage() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Event Types state
const [isEventTypeModalOpen, setIsEventTypeModalOpen] = useState(false);
const [editingEventType, setEditingEventType] = useState<
(EventTypeConfig & { id: string }) | null
>(null);
const [eventTypeFormData, setEventTypeFormData] = useState<
Partial<EventTypeConfig> & { isActive?: boolean }
>({
name: "",
description: "",
duration: 60,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
// Fetch data
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: events = [], isLoading: eventsLoading } = useEventsByTablo(
selectedTabloId !== "all" ? selectedTabloId : null
);
const { data: tabloAccess } = useGetAllTabloAccess();
const { eventTypes: eventTypesData, addEventType, updateEventType } = useEventTypes();
// Filter and search events
const filteredEvents = useMemo(() => {
@ -138,62 +115,6 @@ export function EventsPage() {
setCurrentPage(1);
}, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]);
// Event Types handlers
const handleCreateEventType = () => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de type d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
setEditingEventType(null);
setEventTypeFormData({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
setIsEventTypeModalOpen(true);
};
const handleEditEventType = (id: string, eventType: EventTypeConfig) => {
setEditingEventType({ id, ...eventType });
setEventTypeFormData(eventType as EventTypeConfig);
setIsEventTypeModalOpen(true);
};
const handleSaveEventType = () => {
if (!eventTypeFormData.name) {
toast.add({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
type: "error",
});
return;
}
if (editingEventType) {
updateEventType({
id: editingEventType.id,
eventType: eventTypeFormData as EventTypeConfig,
});
} else {
addEventType({ eventType: eventTypeFormData as EventTypeConfig });
}
setIsEventTypeModalOpen(false);
setEditingEventType(null);
};
// Events handlers
const formatEventDateTime = (event: EventAndTablo) => {
if (!event.start_date) return "Date non définie";
@ -299,34 +220,16 @@ export function EventsPage() {
</div>
</header>
{/* Main Content with Tabs */}
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "events" | "event-types")}
className="w-full"
>
<div className="flex items-center justify-between mb-6">
<TabsList>
<TabsTrigger value="events">{t("pages:events.tabs.events")}</TabsTrigger>
<TabsTrigger value="event-types">{t("pages:events.tabs.eventTypes")}</TabsTrigger>
</TabsList>
<div className="flex items-center justify-end mb-6">
<Button onClick={handleCreateEvent} disabled={isReadOnly}>
<CalendarIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEvent")}
</Button>
</div>
{activeTab === "events" ? (
<Button onClick={handleCreateEvent} disabled={isReadOnly}>
<CalendarIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEvent")}
</Button>
) : (
<Button onClick={handleCreateEventType} disabled={isReadOnly}>
<PlusIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEventType")}
</Button>
)}
</div>
{/* Events Tab */}
<TabsContent value="events" className="space-y-6">
<div className="space-y-6">
{/* Filters */}
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
@ -606,38 +509,7 @@ export function EventsPage() {
</div>
</div>
)}
</TabsContent>
{/* Event Types Tab */}
<TabsContent value="event-types" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventTypesData?.map((eventType) => (
<EventTypeCard
key={eventType.id}
eventType={eventType}
handleEditEventType={handleEditEventType}
/>
))}
</div>
{eventTypesData?.length === 0 && (
<div className="text-center py-12 bg-card rounded-lg shadow-sm border border-border">
<Text className="text-muted-foreground mb-4">
{t("pages:events.eventTypes.emptyState.title")}
</Text>
<Button
variant="default"
size="lg"
onClick={handleCreateEventType}
disabled={isReadOnly}
>
<PlusIcon className="w-4 h-4 mr-2" />{" "}
{t("pages:events.eventTypes.emptyState.button")}
</Button>
</div>
)}
</TabsContent>
</Tabs>
</div>
{/* Event Details Modal */}
<EventDetailsModal
@ -651,15 +523,6 @@ export function EventsPage() {
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
/>
{/* Event Type Modal */}
<EventTypeModal
isModalOpen={isEventTypeModalOpen}
setIsModalOpen={setIsEventTypeModalOpen}
editingEventType={editingEventType}
formData={eventTypeFormData as EventTypeConfig}
setFormData={setEventTypeFormData}
handleSaveEventType={handleSaveEventType}
/>
</main>
{/* Render child routes (e.g. EventModal) */}

View file

@ -55,6 +55,7 @@ import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloTasksSection } from "../components/TabloTasksSection";
import { useTabloDiscussionUnread } from "../hooks/channel";
import { useInviteUser } from "../hooks/invite";
import { useTabloFileNames } from "../hooks/tablo_data";
import {
@ -70,6 +71,7 @@ import {
useUpdateTask,
} from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
import { getEtapeProgressStats } from "../utils/etapeProgress";
// ─── Icon helpers ─────────────────────────────────────────────────────────────
@ -119,21 +121,18 @@ function getStatusConfig(status: string) {
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",
progress: 50,
};
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",
progress: 100,
};
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",
progress: 0,
};
}
}
@ -174,11 +173,13 @@ export const TabloDetailsPage = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { data: tablos, isLoading } = useTablosList();
const { hasUnread: hasUnreadDiscussion } = useTabloDiscussionUnread(tabloId);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<
Date | undefined
>(undefined);
const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
@ -188,6 +189,7 @@ export const TabloDetailsPage = () => {
const { mutate: cancelInvite, isPending: isCancellingInvite } =
useCancelTabloInvite();
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const { mutate: updateTask } = useUpdateTask();
const isEmailValid = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -249,6 +251,10 @@ export const TabloDetailsPage = () => {
const tabloTasks = (allTasks as KanbanTask[]).filter(
(t) => t.tablo_id === tabloId,
);
const myTabloTasks = tabloTasks.filter((task) => task.assignee_id === currentUser.id);
const visibleOverviewTasks = showAllOverviewTasks
? myTabloTasks
: myTabloTasks.slice(0, 5);
// Etapes (parent tasks) for this tablo
const { data: etapes = [] } = useTabloEtapes(tabloId);
@ -269,11 +275,8 @@ export const TabloDetailsPage = () => {
if (!tablo) return null;
const {
label: statusLabel,
badgeClass,
progress,
} = getStatusConfig(tablo.status);
const { label: statusLabel, badgeClass } = getStatusConfig(tablo.status);
const progress = getEtapeProgressStats(etapes);
const isAdmin = tablo.is_admin;
const TabloIcon = getTabloIcon(tablo.color);
const iconColor = getTabloIconColor(tablo.color);
@ -357,13 +360,17 @@ export const TabloDetailsPage = () => {
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Progression :</span>
<div className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div className="relative w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${progress}%` }}
className="absolute inset-y-0 left-0 bg-blue-500/40"
style={{ width: `${progress.startedPercentage}%` }}
/>
<div
className="absolute inset-y-0 left-0 bg-green-500"
style={{ width: `${progress.donePercentage}%` }}
/>
</div>
<span className="text-foreground font-medium">{progress}%</span>
<span className="text-foreground font-medium">{progress.donePercentage}%</span>
</div>
</div>
</div>
@ -390,7 +397,12 @@ export const TabloDetailsPage = () => {
tab.disabled && "opacity-40 cursor-not-allowed",
)}
>
<tab.icon className="w-4 h-4" />
<span className="relative inline-flex">
<tab.icon className="w-4 h-4" />
{tab.id === "discussion" && hasUnreadDiscussion && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-red-500" />
)}
</span>
<span>{tab.label}</span>
{tab.disabled && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none">
@ -438,22 +450,38 @@ export const TabloDetailsPage = () => {
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{tabloTasks.length === 0 ? (
{myTabloTasks.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">
Aucune tâche
</div>
) : (
tabloTasks.slice(0, 5).map((task) => (
visibleOverviewTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
onClick={() => setSearchParams({ section: "tasks" })}
>
{task.status === "done" ? (
<CircleCheckIcon className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
)}
<button
type="button"
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
if (task.status !== "done") {
updateTask({ id: task.id, status: "done" });
}
}}
aria-label={
task.status === "done"
? "Tâche terminée"
: "Marquer la tâche comme terminée"
}
>
{task.status === "done" ? (
<CircleCheckIcon className="w-5 h-5 text-green-500" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600" />
)}
</button>
<p
className={cn(
"text-sm font-medium truncate",
@ -467,13 +495,15 @@ export const TabloDetailsPage = () => {
</div>
))
)}
{tabloTasks.length > 5 && (
{myTabloTasks.length > 5 && (
<button
type="button"
onClick={() => setSearchParams({ section: "tasks" })}
onClick={() => setShowAllOverviewTasks((prev) => !prev)}
className="w-full p-3 text-sm text-[#804EEC] hover:underline text-center"
>
Voir les {tabloTasks.length - 5} tâches restantes
{showAllOverviewTasks
? "Voir moins"
: `Voir les ${myTabloTasks.length - 5} tâches restantes`}
</button>
)}
</div>

View file

@ -4,18 +4,25 @@ import { renderWithProviders } from "../utils/testHelpers";
import { TabloPage } from "./tablo";
vi.mock("../hooks/tablos", () => ({
useTablo: () => ({
tablo: {
id: "test-tablo-id",
name: "Test Tablo",
owner_id: "test-owner-id",
},
useTablosList: () => ({
data: [
{
id: "test-tablo-id",
name: "Test Tablo",
color: "bg-blue-500",
image: null,
created_at: "2024-01-01T00:00:00Z",
deleted_at: null,
position: 0,
status: "todo",
user_id: "test-user-id",
is_admin: true,
access_level: "admin",
},
],
isLoading: false,
error: null,
}),
useTablosList: () => ({
data: [{ id: "test-tablo-id", name: "Test Tablo" }],
}),
useCreateTablo: () => ({
mutate: vi.fn(),
}),
@ -26,6 +33,10 @@ vi.mock("../hooks/tablos", () => ({
mutate: vi.fn(),
}),
useCanCreateTablo: () => true,
useTabloMembers: () => ({
data: [],
isLoading: false,
}),
}));
vi.mock("../hooks/tabloData", () => ({

View file

@ -0,0 +1,35 @@
import type { Etape } from "@xtablo/shared-types";
const STARTED_ETAPE_STATUSES = new Set(["in_progress", "in_review", "done"]);
export interface EtapeProgressStats {
total: number;
started: number;
done: number;
startedPercentage: number;
donePercentage: number;
}
export function getEtapeProgressStats(etapes: Etape[]): EtapeProgressStats {
const total = etapes.length;
const done = etapes.filter((etape) => etape.status === "done").length;
const started = etapes.filter((etape) => STARTED_ETAPE_STATUSES.has(etape.status ?? "todo")).length;
if (total === 0) {
return {
total: 0,
started: 0,
done: 0,
startedPercentage: 0,
donePercentage: 0,
};
}
return {
total,
started,
done,
startedPercentage: Math.round((started / total) * 100),
donePercentage: Math.round((done / total) * 100),
};
}

View file

@ -606,6 +606,7 @@ export type Database = {
Row: {
assignee_id: string | null;
created_at: string;
deleted_at: string | null;
description: string | null;
due_date: string | null;
id: string;
@ -620,6 +621,7 @@ export type Database = {
Insert: {
assignee_id?: string | null;
created_at?: string;
deleted_at?: string | null;
description?: string | null;
due_date?: string | null;
id?: string;
@ -634,6 +636,7 @@ export type Database = {
Update: {
assignee_id?: string | null;
created_at?: string;
deleted_at?: string | null;
description?: string | null;
due_date?: string | null;
id?: string;

View file

@ -0,0 +1,34 @@
-- Add soft-delete support for tasks
ALTER TABLE "public"."tasks"
ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
COMMENT ON COLUMN "public"."tasks"."deleted_at" IS 'Timestamp when the task was soft-deleted';
-- Index used by tablo-scoped cleanup and active-task reads
CREATE INDEX IF NOT EXISTS "tasks_tablo_deleted_at_idx"
ON "public"."tasks" USING btree ("tablo_id", "deleted_at");
-- Keep tasks_with_assignee limited to active (not soft-deleted) tasks
CREATE OR REPLACE VIEW "public"."tasks_with_assignee" WITH ("security_invoker"='true') AS
SELECT
t.id,
t.tablo_id,
t.title,
t.description,
t.status,
t.assignee_id,
t.position,
t.created_at,
t.updated_at,
p.name AS assignee_name,
p.avatar_url AS assignee_avatar,
t.is_parent,
t.parent_task_id,
t.due_date
FROM "public"."tasks" t
LEFT JOIN "public"."profiles" p ON t.assignee_id = p.id
WHERE t.deleted_at IS NULL;
ALTER TABLE "public"."tasks_with_assignee" OWNER TO "postgres";
COMMENT ON VIEW "public"."tasks_with_assignee" IS 'View that returns active (non-deleted) tasks with assignee information from profiles';