commit
1533a22a11
37 changed files with 1069 additions and 333 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ vi.mock("../hooks/tablo_invites", () => ({
|
|||
usePendingTabloInvitesByTablo: () => ({
|
||||
data: [],
|
||||
}),
|
||||
useCancelTabloInvite: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/invite", () => ({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -26,5 +26,10 @@
|
|||
"noNotifications": "Aucune nouvelle notification",
|
||||
"allCaughtUp": "Vous êtes à jour !",
|
||||
"viewAll": "Voir toutes les notifications"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invitations",
|
||||
"dismiss": "Ignorer",
|
||||
"accept": "Accepter"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
35
apps/main/src/utils/etapeProgress.ts
Normal file
35
apps/main/src/utils/etapeProgress.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
Loading…
Reference in a new issue