commit
f2a35a85dc
59 changed files with 9060 additions and 2320 deletions
|
|
@ -5,17 +5,26 @@ import {
|
|||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import type { TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { sdkStreamMixin } from "@smithy/util-stream";
|
||||
import { mockClient } from "aws-sdk-client-mock";
|
||||
import { testClient } from "hono/testing";
|
||||
import { Readable } from "stream";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
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 type { TestUserData } from "../helpers/dbSetup.js";
|
||||
import { getTestUser } from "../helpers/dbSetup.js";
|
||||
|
||||
// Helper to create S3 stream from content
|
||||
const createS3Stream = (content: string) => {
|
||||
const stream = new Readable();
|
||||
stream.push(content);
|
||||
stream.push(null);
|
||||
return sdkStreamMixin(stream);
|
||||
};
|
||||
|
||||
// Create S3 mock
|
||||
const s3Mock = mockClient(S3Client);
|
||||
|
||||
|
|
@ -43,6 +52,9 @@ describe("TabloData Endpoint", () => {
|
|||
return res;
|
||||
};
|
||||
|
||||
// Store folder metadata state for tests
|
||||
let mockFolderMetadata: TabloFoldersMetadata = { folders: [], version: 1 };
|
||||
|
||||
beforeAll(() => {
|
||||
// Reset mocks before all tests
|
||||
s3Mock.reset();
|
||||
|
|
@ -56,26 +68,48 @@ describe("TabloData Endpoint", () => {
|
|||
],
|
||||
});
|
||||
|
||||
// Mock GetObjectCommand (used by getTabloFile)
|
||||
// Create a proper SDK stream from Readable
|
||||
const stream = new Readable();
|
||||
stream.push("test file content");
|
||||
stream.push(null);
|
||||
const sdkStream = sdkStreamMixin(stream);
|
||||
|
||||
s3Mock.on(GetObjectCommand).resolves({
|
||||
Body: sdkStream,
|
||||
ContentType: "text/plain",
|
||||
LastModified: new Date("2025-11-12"),
|
||||
// Mock GetObjectCommand (used by getTabloFile and getFolderMetadata)
|
||||
s3Mock.on(GetObjectCommand).callsFake((input) => {
|
||||
// Handle folder metadata requests
|
||||
if (input.Key?.endsWith(".tablo-folders.json")) {
|
||||
if (mockFolderMetadata.folders.length === 0 && mockFolderMetadata.version === 1) {
|
||||
// Return empty metadata (simulates file not found)
|
||||
return Promise.resolve({
|
||||
Body: createS3Stream(JSON.stringify(mockFolderMetadata)),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
Body: createS3Stream(JSON.stringify(mockFolderMetadata)),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
}
|
||||
// Handle regular file requests
|
||||
return Promise.resolve({
|
||||
Body: createS3Stream("test file content"),
|
||||
ContentType: "text/plain",
|
||||
LastModified: new Date("2025-11-12"),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock PutObjectCommand (used by postTabloFile)
|
||||
s3Mock.on(PutObjectCommand).resolves({});
|
||||
// Mock PutObjectCommand (used by postTabloFile and saveFolderMetadata)
|
||||
s3Mock.on(PutObjectCommand).callsFake((input) => {
|
||||
// Handle folder metadata saves
|
||||
if (input.Key?.endsWith(".tablo-folders.json") && input.Body) {
|
||||
mockFolderMetadata = JSON.parse(input.Body as string);
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
// Mock DeleteObjectCommand (used by deleteTabloFile)
|
||||
s3Mock.on(DeleteObjectCommand).resolves({});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset folder metadata before each test
|
||||
mockFolderMetadata = { folders: [], version: 1 };
|
||||
});
|
||||
|
||||
describe("GET /tablo-data/:tabloId/filenames - Owner Access", () => {
|
||||
it("should allow owner to access their private tablo", async () => {
|
||||
const res = await getTabloFileNamesResponse(ownerUser, client, "test_tablo_owner_private");
|
||||
|
|
@ -491,4 +525,632 @@ describe("TabloData Endpoint", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// FOLDER ENDPOINT TESTS
|
||||
// ============================================
|
||||
|
||||
describe("GET /tablo-data/:tabloId/folders - Get Folders (Member Access)", () => {
|
||||
// Helper function to get folders
|
||||
const getFoldersRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"].$get(
|
||||
{ param: { tabloId } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
describe("Owner Access", () => {
|
||||
it("should allow owner to get folders from their own tablo", async () => {
|
||||
const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_private");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folders).toEqual([]);
|
||||
expect(data.version).toBe(1);
|
||||
});
|
||||
|
||||
it("should allow owner to get folders from their shared tablo", async () => {
|
||||
const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_shared");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User Access", () => {
|
||||
it("should allow temp user to get folders from owner's shared tablo", async () => {
|
||||
const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_shared");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny temp user access to owner's private tablo folders", async () => {
|
||||
const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_private");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated access to folders", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$get({
|
||||
param: { tabloId: "test_tablo_owner_private" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tablo-data/:tabloId/folders - Create Folder (Admin Only)", () => {
|
||||
// Helper function to create folder
|
||||
const createFolderRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
name: string,
|
||||
description?: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId }, json: { name, description } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
describe("Owner with Admin Access", () => {
|
||||
it("should allow owner to create folder in their own tablo", async () => {
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"My Folder",
|
||||
"Test description"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("Folder created successfully");
|
||||
expect(data.folder.name).toBe("My Folder");
|
||||
expect(data.folder.description).toBe("Test description");
|
||||
expect(data.folder.id).toMatch(/^folder-/);
|
||||
});
|
||||
|
||||
it("should allow owner to create folder without description", async () => {
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"Folder Without Description"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folder.name).toBe("Folder Without Description");
|
||||
expect(data.folder.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 400 if folder name is missing", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId: "test_tablo_owner_private" }, json: {} },
|
||||
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder name is required");
|
||||
});
|
||||
|
||||
it("should return 400 if folder name is empty string", async () => {
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
" "
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder name is required");
|
||||
});
|
||||
|
||||
it("should return 400 for duplicate folder names", async () => {
|
||||
// Create first folder
|
||||
await createFolderRequest(ownerUser, client, "test_tablo_owner_private", "Duplicate Name");
|
||||
|
||||
// Try to create folder with same name
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"duplicate name" // Case insensitive check
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("A folder with this name already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User - Blocked by regularUserCheck", () => {
|
||||
it("should deny temp user from creating folder (regularUserCheck)", async () => {
|
||||
const res = await createFolderRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"Temp Folder"
|
||||
);
|
||||
|
||||
// Temporary users are blocked by regularUserCheck middleware
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated folder creation", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post({
|
||||
param: { tabloId: "test_tablo_owner_private" },
|
||||
json: { name: "Test Folder" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /tablo-data/:tabloId/folders/:folderId - Update Folder (Admin Only)", () => {
|
||||
// Helper function to update folder
|
||||
const updateFolderRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
folderId: string,
|
||||
name?: string,
|
||||
description?: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put(
|
||||
{ param: { tabloId, folderId }, json: { name, description } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create a folder and return its ID
|
||||
const createTestFolder = async (tabloId: string, name: string): Promise<string> => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId }, json: { name } },
|
||||
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
return data.folder.id;
|
||||
};
|
||||
|
||||
describe("Owner with Admin Access", () => {
|
||||
it("should allow owner to update folder name", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "Original Name");
|
||||
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
"Updated Name"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("Folder updated successfully");
|
||||
expect(data.folder.name).toBe("Updated Name");
|
||||
});
|
||||
|
||||
it("should allow owner to update folder description", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder");
|
||||
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
undefined,
|
||||
"New description"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folder.description).toBe("New description");
|
||||
});
|
||||
|
||||
it("should allow owner to clear folder description", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder");
|
||||
|
||||
// First set a description
|
||||
await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
undefined,
|
||||
"Some description"
|
||||
);
|
||||
|
||||
// Then clear it
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
undefined,
|
||||
""
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folder.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 404 for non-existent folder", async () => {
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"non-existent-folder-id",
|
||||
"New Name"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder not found");
|
||||
});
|
||||
|
||||
it("should return 400 for duplicate folder names on update", async () => {
|
||||
const folderId1 = await createTestFolder("test_tablo_owner_private", "Folder One");
|
||||
await createTestFolder("test_tablo_owner_private", "Folder Two");
|
||||
|
||||
// Try to rename folder 1 to folder 2's name
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId1,
|
||||
"Folder Two"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("A folder with this name already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User - Blocked by regularUserCheck", () => {
|
||||
it("should deny temp user from updating folder (regularUserCheck)", async () => {
|
||||
const res = await updateFolderRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"some-folder-id",
|
||||
"New Name"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated folder update", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put({
|
||||
param: { tabloId: "test_tablo_owner_private", folderId: "some-id" },
|
||||
json: { name: "New Name" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /tablo-data/:tabloId/folders/:folderId - Delete Folder (Admin Only)", () => {
|
||||
// Helper function to delete folder
|
||||
const deleteFolderRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
folderId: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete(
|
||||
{ param: { tabloId, folderId } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create a folder and return its ID
|
||||
const createTestFolder = async (tabloId: string, name: string): Promise<string> => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId }, json: { name } },
|
||||
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
return data.folder.id;
|
||||
};
|
||||
|
||||
describe("Owner with Admin Access", () => {
|
||||
it("should allow owner to delete folder from their own tablo", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "To Delete");
|
||||
|
||||
const res = await deleteFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("Folder deleted successfully");
|
||||
expect(data.folder.name).toBe("To Delete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 404 for non-existent folder", async () => {
|
||||
const res = await deleteFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"non-existent-folder-id"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User - Blocked by regularUserCheck", () => {
|
||||
it("should deny temp user from deleting folder (regularUserCheck)", async () => {
|
||||
const res = await deleteFolderRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"some-folder-id"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated folder deletion", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete({
|
||||
param: { tabloId: "test_tablo_owner_private", folderId: "some-id" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// NESTED FILE PATH TESTS (/:tabloId/file/:path)
|
||||
// ============================================
|
||||
|
||||
describe("Nested File Path Routes (/file/:path)", () => {
|
||||
// Helper function to get file with nested path
|
||||
const getNestedFileRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
filePath: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["file"][":path"].$get(
|
||||
{ param: { tabloId, path: filePath } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to post file with nested path
|
||||
const postNestedFileRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
filePath: string,
|
||||
content: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["file"][":path"].$post(
|
||||
{ param: { tabloId, path: filePath }, json: { content, contentType: "text/plain" } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to delete file with nested path
|
||||
const deleteNestedFileRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
filePath: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["file"][":path"].$delete(
|
||||
{ param: { tabloId, path: filePath } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
describe("GET /tablo-data/:tabloId/file/:path - Get File with Nested Path", () => {
|
||||
it("should allow owner to get file with nested path", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/document.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.fileName).toBe("folder-123/document.pdf");
|
||||
expect(data.content).toBe("test file content");
|
||||
});
|
||||
|
||||
it("should allow owner to get file with deeply nested path", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/subfolder/deep/file.txt"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.fileName).toBe("folder-123/subfolder/deep/file.txt");
|
||||
});
|
||||
|
||||
it("should allow member to get nested file from shared tablo", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_shared",
|
||||
"folder-456/report.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny access to nested file in private tablo", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/secret.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should deny unauthenticated access to nested file", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$get({
|
||||
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tablo-data/:tabloId/file/:path - Upload File with Nested Path", () => {
|
||||
it("should allow owner to upload file with nested path", async () => {
|
||||
const res = await postNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/new-document.pdf",
|
||||
"Document content"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("File uploaded successfully");
|
||||
expect(data.fileName).toBe("folder-123/new-document.pdf");
|
||||
});
|
||||
|
||||
it("should allow member to upload file with nested path to shared tablo", async () => {
|
||||
const res = await postNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_shared",
|
||||
"folder-789/upload.txt",
|
||||
"Uploaded content"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny member from uploading nested file to private tablo", async () => {
|
||||
const res = await postNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/unauthorized.txt",
|
||||
"Should fail"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should deny unauthenticated nested file upload", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$post({
|
||||
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
|
||||
json: { content: "test" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin Only)", () => {
|
||||
it("should allow admin to delete file with nested path", async () => {
|
||||
const res = await deleteNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/to-delete.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("File deleted successfully");
|
||||
expect(data.fileName).toBe("folder-123/to-delete.pdf");
|
||||
});
|
||||
|
||||
it("should allow admin to delete deeply nested file", async () => {
|
||||
const res = await deleteNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/level2/level3/deep-file.txt"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny temp user from deleting nested file (regularUserCheck)", async () => {
|
||||
const res = await deleteNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"folder-123/file.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
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
|
||||
const res = await deleteNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_temp_shared_admin",
|
||||
"folder/file.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should deny unauthenticated nested file deletion", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$delete({
|
||||
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,11 +116,13 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) =>
|
|||
const { Contents } = await s3_client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
Prefix: tabloId,
|
||||
Prefix: `${tabloId}/`,
|
||||
})
|
||||
);
|
||||
|
||||
return Contents?.map((content) => content.Key?.split("/")[1]).filter(
|
||||
// Return full file paths relative to tabloId (e.g., "file.pdf" or "folder-123/file.pdf")
|
||||
const prefix = `${tabloId}/`;
|
||||
return Contents?.map((content) => content.Key?.substring(prefix.length)).filter(
|
||||
(content) => content?.length && content.length > 0
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async function startServer(secrets: Secrets) {
|
|||
"Access-Control-Allow-Credentials",
|
||||
"Access-Control-Expose-Headers",
|
||||
],
|
||||
allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "OPTIONS", "DELETE"],
|
||||
exposeHeaders: ["set-cookie"],
|
||||
credentials: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "../helpers/helpers.js";
|
||||
|
|
@ -7,6 +13,16 @@ import type { AuthEnv } from "../types/app.types.js";
|
|||
|
||||
const factory = createFactory<AuthEnv>();
|
||||
|
||||
// Metadata file name for folders
|
||||
const FOLDERS_METADATA_FILE = ".tablo-folders.json";
|
||||
|
||||
// Helper to generate unique folder IDs
|
||||
const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// ============================================
|
||||
// FILE ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
|
@ -20,19 +36,55 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) =>
|
|||
}
|
||||
});
|
||||
|
||||
// Returns file names for all tablos the authenticated user has access to, in one request
|
||||
const getAllTablosFilenames = factory.createHandlers(async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const user = c.get("user");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const { data: tabloAccess, error } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("tablo_id")
|
||||
.eq("user_id", user.id)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: "Failed to fetch tablos" }, 500);
|
||||
}
|
||||
|
||||
const tabloIds = (tabloAccess ?? []).map((row: { tablo_id: string }) => row.tablo_id);
|
||||
|
||||
const results = await Promise.all(
|
||||
tabloIds.map(async (tabloId: string) => {
|
||||
const fileNames = await getTabloFileNames(s3_client, tabloId);
|
||||
return { tabloId, fileNames: fileNames ?? [] };
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ tablos: results });
|
||||
} catch (error) {
|
||||
console.error("Error fetching all tablo files:", error);
|
||||
return c.json({ error: "Failed to fetch all tablo files" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const fileName = c.req.param("fileName");
|
||||
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
|
||||
const filePath = c.req.param("path") || c.req.param("fileName");
|
||||
|
||||
if (!filePath) {
|
||||
return c.json({ error: "File path is required" }, 400);
|
||||
}
|
||||
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
const response = await s3_client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${fileName}`,
|
||||
Key: `${tabloId}/${filePath}`,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -43,7 +95,7 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
const content = await response.Body.transformToString();
|
||||
|
||||
return c.json({
|
||||
fileName,
|
||||
fileName: filePath,
|
||||
content,
|
||||
contentType: response.ContentType,
|
||||
lastModified: response.LastModified,
|
||||
|
|
@ -56,7 +108,12 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
|
||||
const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const fileName = c.req.param("fileName");
|
||||
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
|
||||
const filePath = c.req.param("path") || c.req.param("fileName");
|
||||
|
||||
if (!filePath) {
|
||||
return c.json({ error: "File path is required" }, 400);
|
||||
}
|
||||
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
|
|
@ -71,7 +128,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
await s3_client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${fileName}`,
|
||||
Key: `${tabloId}/${filePath}`,
|
||||
Body: content,
|
||||
ContentType: contentType,
|
||||
})
|
||||
|
|
@ -79,7 +136,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
|
||||
return c.json({
|
||||
message: "File uploaded successfully",
|
||||
fileName,
|
||||
fileName: filePath,
|
||||
tabloId,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -88,61 +145,29 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// // PUT /tablo-data/:tabloId/:fileName - Update a file
|
||||
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
|
||||
// const tabloId = c.req.param("tabloId");
|
||||
// const fileName = c.req.param("fileName");
|
||||
// const s3_client = c.get("s3_client");
|
||||
|
||||
// try {
|
||||
// const body = await c.req.json();
|
||||
// const { content, contentType = "text/plain" } = body;
|
||||
|
||||
// if (!content) {
|
||||
// return c.json({ error: "Content is required" }, 400);
|
||||
// }
|
||||
|
||||
// const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
// await s3_client.send(
|
||||
// new PutObjectCommand({
|
||||
// Bucket: "tablo-data",
|
||||
// Key: `${tabloId}/${fileName}`,
|
||||
// Body: content,
|
||||
// ContentType: contentType,
|
||||
// })
|
||||
// );
|
||||
|
||||
// return c.json({
|
||||
// message: "File updated successfully",
|
||||
// fileName,
|
||||
// tabloId,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error updating file:", error);
|
||||
// return c.json({ error: "Failed to update file" }, 500);
|
||||
// }
|
||||
// });
|
||||
|
||||
const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const fileName = c.req.param("fileName");
|
||||
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
|
||||
const filePath = c.req.param("path") || c.req.param("fileName");
|
||||
|
||||
if (!filePath) {
|
||||
return c.json({ error: "File path is required" }, 400);
|
||||
}
|
||||
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
await s3_client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${fileName}`,
|
||||
Key: `${tabloId}/${filePath}`,
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({
|
||||
message: "File deleted successfully",
|
||||
fileName,
|
||||
fileName: filePath,
|
||||
tabloId,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -151,6 +176,193 @@ const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.
|
|||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// FOLDER ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
// Helper to get or create folder metadata
|
||||
const getFolderMetadata = async (
|
||||
s3_client: S3Client,
|
||||
tabloId: string
|
||||
): Promise<TabloFoldersMetadata> => {
|
||||
try {
|
||||
const response = await s3_client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${FOLDERS_METADATA_FILE}`,
|
||||
})
|
||||
);
|
||||
|
||||
if (response.Body) {
|
||||
const content = await response.Body.transformToString();
|
||||
return JSON.parse(content);
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, return default
|
||||
}
|
||||
return { folders: [], version: 1 };
|
||||
};
|
||||
|
||||
// Helper to save folder metadata
|
||||
const saveFolderMetadata = async (
|
||||
s3_client: S3Client,
|
||||
tabloId: string,
|
||||
metadata: TabloFoldersMetadata
|
||||
) => {
|
||||
await s3_client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${FOLDERS_METADATA_FILE}`,
|
||||
Body: JSON.stringify(metadata, null, 2),
|
||||
ContentType: "application/json",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// GET /tablo-data/:tabloId/folders - Get all folders for a tablo
|
||||
const getTabloFolders = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
return c.json(metadata);
|
||||
} catch (error) {
|
||||
console.error("Error fetching folders:", error);
|
||||
return c.json({ error: "Failed to fetch folders" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /tablo-data/:tabloId/folders - Create a new folder (admin only)
|
||||
const createTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
const user = c.get("user");
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { name, description } = body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return c.json({ error: "Folder name is required" }, 400);
|
||||
}
|
||||
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
|
||||
// Check for duplicate folder names
|
||||
if (metadata.folders.some((f) => f.name.toLowerCase() === name.trim().toLowerCase())) {
|
||||
return c.json({ error: "A folder with this name already exists" }, 400);
|
||||
}
|
||||
|
||||
const newFolder: TabloFolder = {
|
||||
id: generateFolderId(),
|
||||
name: name.trim(),
|
||||
description: description?.trim() || undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: user.id,
|
||||
};
|
||||
|
||||
metadata.folders.push(newFolder);
|
||||
metadata.version += 1;
|
||||
|
||||
await saveFolderMetadata(s3_client, tabloId, metadata);
|
||||
|
||||
return c.json({
|
||||
message: "Folder created successfully",
|
||||
folder: newFolder,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating folder:", error);
|
||||
return c.json({ error: "Failed to create folder" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /tablo-data/:tabloId/folders/:folderId - Update a folder (admin only)
|
||||
const updateTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const folderId = c.req.param("folderId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { name, description } = body;
|
||||
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
const folderIndex = metadata.folders.findIndex((f) => f.id === folderId);
|
||||
|
||||
if (folderIndex === -1) {
|
||||
return c.json({ error: "Folder not found" }, 404);
|
||||
}
|
||||
|
||||
// Check for duplicate folder names (excluding current folder)
|
||||
if (
|
||||
name &&
|
||||
metadata.folders.some(
|
||||
(f, idx) => idx !== folderIndex && f.name.toLowerCase() === name.trim().toLowerCase()
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "A folder with this name already exists" }, 400);
|
||||
}
|
||||
|
||||
// Update folder
|
||||
if (name) {
|
||||
metadata.folders[folderIndex].name = name.trim();
|
||||
}
|
||||
if (description !== undefined) {
|
||||
metadata.folders[folderIndex].description = description?.trim() || undefined;
|
||||
}
|
||||
metadata.version += 1;
|
||||
|
||||
await saveFolderMetadata(s3_client, tabloId, metadata);
|
||||
|
||||
return c.json({
|
||||
message: "Folder updated successfully",
|
||||
folder: metadata.folders[folderIndex],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating folder:", error);
|
||||
return c.json({ error: "Failed to update folder" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /tablo-data/:tabloId/folders/:folderId - Delete a folder (admin only)
|
||||
const deleteTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const folderId = c.req.param("folderId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
const folderIndex = metadata.folders.findIndex((f) => f.id === folderId);
|
||||
|
||||
if (folderIndex === -1) {
|
||||
return c.json({ error: "Folder not found" }, 404);
|
||||
}
|
||||
|
||||
const deletedFolder = metadata.folders[folderIndex];
|
||||
metadata.folders.splice(folderIndex, 1);
|
||||
metadata.version += 1;
|
||||
|
||||
await saveFolderMetadata(s3_client, tabloId, metadata);
|
||||
|
||||
// Note: Files in the folder are NOT deleted, they become "orphaned" / unorganized
|
||||
return c.json({
|
||||
message: "Folder deleted successfully",
|
||||
folder: deletedFolder,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting folder:", error);
|
||||
return c.json({ error: "Failed to delete folder" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTER SETUP
|
||||
// ============================================
|
||||
|
||||
export const getTabloDataRouter = () => {
|
||||
const tabloDataRouter = new Hono();
|
||||
const middlewareManager = MiddlewareManager.getInstance();
|
||||
|
|
@ -159,7 +371,25 @@ export const getTabloDataRouter = () => {
|
|||
tabloDataRouter.use(middlewareManager.streamChat);
|
||||
tabloDataRouter.use(middlewareManager.r2);
|
||||
|
||||
// All-tablos file listing (must be before /:tabloId routes)
|
||||
tabloDataRouter.get("/all-filenames", ...getAllTablosFilenames);
|
||||
|
||||
// File endpoints
|
||||
tabloDataRouter.get("/:tabloId/filenames", ...getTabloFilenames);
|
||||
|
||||
// Folder endpoints (must be defined before wildcard file routes)
|
||||
tabloDataRouter.get("/:tabloId/folders", ...getTabloFolders);
|
||||
tabloDataRouter.post("/:tabloId/folders", ...createTabloFolder(middlewareManager));
|
||||
tabloDataRouter.put("/:tabloId/folders/:folderId", ...updateTabloFolder(middlewareManager));
|
||||
tabloDataRouter.delete("/:tabloId/folders/:folderId", ...deleteTabloFolder(middlewareManager));
|
||||
|
||||
// File routes using wildcard to support nested paths (e.g., "folder-123/file.pdf")
|
||||
// These must be defined after the specific routes above
|
||||
tabloDataRouter.get("/:tabloId/file/:path{.+}", ...getTabloFile);
|
||||
tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile);
|
||||
tabloDataRouter.delete("/:tabloId/file/:path{.+}", ...deleteTabloFile(middlewareManager));
|
||||
|
||||
// Legacy routes for backward compatibility (single-level file names only)
|
||||
tabloDataRouter.get("/:tabloId/:fileName", ...getTabloFile);
|
||||
tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile);
|
||||
tabloDataRouter.delete("/:tabloId/:fileName", ...deleteTabloFile(middlewareManager));
|
||||
|
|
|
|||
91
apps/main/src/components/ActionCard.tsx
Normal file
91
apps/main/src/components/ActionCard.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { cn } from "@xtablo/shared";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface ActionCardProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
variant?: "primary" | "default";
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionCard({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
variant = "default",
|
||||
isSelected = false,
|
||||
disabled = false,
|
||||
badge,
|
||||
onClick,
|
||||
className,
|
||||
}: ActionCardProps) {
|
||||
const isPrimary = variant === "primary";
|
||||
const isActive = !disabled && (isSelected || isPrimary);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-fit p-3 rounded-2xl text-left transition-all",
|
||||
disabled
|
||||
? "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 opacity-50 cursor-not-allowed"
|
||||
: isSelected
|
||||
? "bg-[rgb(128,78,236)] text-white border-transparent shadow-lg"
|
||||
: isPrimary
|
||||
? "bg-primary text-white hover:shadow-lg"
|
||||
: "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-[8px] flex items-center justify-center flex-shrink-0",
|
||||
isActive ? "bg-white/20" : "bg-[#F4F3FF] dark:bg-purple-900/20",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-6 h-6 flex items-center justify-center",
|
||||
isActive ? "text-white" : "text-[#7F56D9]",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold text-lg leading-tight",
|
||||
isActive ? "text-white" : "text-gray-900 dark:text-gray-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{badge && (
|
||||
<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 shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm mt-0.5",
|
||||
isActive ? "text-purple-100" : "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export const ChannelBadge = ({
|
|||
<div className="relative">
|
||||
<div
|
||||
className={twMerge(
|
||||
"size-12 rounded-full flex items-center justify-center text-white font-semibold text-sm",
|
||||
"size-12 rounded-full flex items-center justify-center text-white font-semibold text-sm bg-[#804EEC]",
|
||||
tablo?.color && tablo.color
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function ChannelPreview({
|
|||
<div
|
||||
className={twMerge(
|
||||
"group relative flex items-center gap-3 p-3 cursor-pointer transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800",
|
||||
isActive && "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800",
|
||||
isActive && "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
|
|
@ -98,7 +98,7 @@ export function ChannelPreview({
|
|||
<h3
|
||||
className={twMerge(
|
||||
"font-medium text-gray-900 dark:text-gray-100 truncate",
|
||||
isActive && "text-blue-600 dark:text-blue-400"
|
||||
isActive && "text-[#804EEC] dark:text-purple-400"
|
||||
)}
|
||||
>
|
||||
{displayTitle}
|
||||
|
|
@ -131,7 +131,7 @@ export function ChannelPreview({
|
|||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500 dark:bg-blue-400 rounded-r-full" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#804EEC] dark:bg-purple-400 rounded-r-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
50
apps/main/src/components/DashboardActionCards.tsx
Normal file
50
apps/main/src/components/DashboardActionCards.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { FolderPlus, MessageCircle, PlusCircle, UserPlus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionCard } from "./ActionCard";
|
||||
|
||||
export interface DashboardActionCardsProps {
|
||||
onCreateProject?: () => void;
|
||||
onCreateTask?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardActionCards({
|
||||
onCreateProject,
|
||||
onCreateTask,
|
||||
onSendMessage,
|
||||
}: DashboardActionCardsProps) {
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
|
||||
<ActionCard
|
||||
icon={<FolderPlus className="w-6 h-6" />}
|
||||
label={t("dashboard.actionCards.createProject.label")}
|
||||
description={t("dashboard.actionCards.createProject.description")}
|
||||
onClick={onCreateProject}
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
icon={<PlusCircle className="w-6 h-6" />}
|
||||
label={t("dashboard.actionCards.createTask.label")}
|
||||
description={t("dashboard.actionCards.createTask.description")}
|
||||
onClick={onCreateTask}
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
icon={<UserPlus className="w-6 h-6" />}
|
||||
label={t("dashboard.actionCards.inviteTeam.label")}
|
||||
description={t("dashboard.actionCards.inviteTeam.description")}
|
||||
disabled
|
||||
badge="Bientôt"
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
icon={<MessageCircle className="w-6 h-6" />}
|
||||
label={t("dashboard.actionCards.sendMessage.label")}
|
||||
description={t("dashboard.actionCards.sendMessage.description")}
|
||||
onClick={onSendMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
apps/main/src/components/DashboardTaskList.tsx
Normal file
199
apps/main/src/components/DashboardTaskList.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { cn } from "@xtablo/shared";
|
||||
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
|
||||
import { CheckCircle2, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { TaskModal } from "./kanban/TaskModal";
|
||||
|
||||
type TaskWithTablo = KanbanTask & {
|
||||
tablos: { id: string; name: string; color: string | null } | null;
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
TaskStatus,
|
||||
{ className: string; labelKey: string }
|
||||
> = {
|
||||
todo: {
|
||||
className:
|
||||
"bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400",
|
||||
labelKey: "dashboard.taskList.status.todo",
|
||||
},
|
||||
in_progress: {
|
||||
className:
|
||||
"bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
labelKey: "dashboard.taskList.status.inProgress",
|
||||
},
|
||||
in_review: {
|
||||
className:
|
||||
"bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
labelKey: "dashboard.taskList.status.inReview",
|
||||
},
|
||||
done: {
|
||||
className:
|
||||
"bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400",
|
||||
labelKey: "dashboard.taskList.status.done",
|
||||
},
|
||||
};
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
onToggleDone,
|
||||
}: {
|
||||
task: TaskWithTablo;
|
||||
onToggleDone: (task: TaskWithTablo) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("pages");
|
||||
const navigate = useNavigate();
|
||||
const status = task.status ?? "todo";
|
||||
const isDone = status === "done";
|
||||
const badge = STATUS_BADGE[status];
|
||||
|
||||
const dateStr = task.updated_at ?? task.created_at;
|
||||
const formattedDate = dateStr
|
||||
? new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(dateStr))
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-[auto_1fr_1fr_auto_auto] items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (task.tablos) {
|
||||
navigate(`/tablos/${task.tablos.id}?section=tasks`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
isDone
|
||||
? "bg-purple-600 border-purple-600"
|
||||
: "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleDone(task);
|
||||
}}
|
||||
>
|
||||
{isDone && <CheckCircle2 className="w-4 h-4 text-white" />}
|
||||
</button>
|
||||
|
||||
{/* Title */}
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isDone
|
||||
? "line-through text-gray-400 dark:text-gray-500"
|
||||
: "text-gray-900 dark:text-gray-100",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
|
||||
{/* Tablo */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{task.tablos && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-lg flex items-center justify-center text-xs shrink-0",
|
||||
task.tablos.color || "bg-gray-400",
|
||||
)}
|
||||
>
|
||||
<span className="text-white font-bold text-[10px]">
|
||||
{task.tablos.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 hidden sm:inline truncate">
|
||||
{task.tablos.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 hidden md:inline whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap",
|
||||
badge.className,
|
||||
)}
|
||||
>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardTaskList() {
|
||||
const { t } = useTranslation("pages");
|
||||
const user = useUser();
|
||||
const { data: allTasks } = useAllTasks();
|
||||
const { data: tablos } = useTablosList();
|
||||
const updateTask = useUpdateTask();
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
|
||||
// Filter to tasks assigned to the current user, limited to recent ones
|
||||
const myTasks =
|
||||
allTasks
|
||||
?.filter((task) => task.assignee_id === user.id)
|
||||
.slice(0, 7) ?? [];
|
||||
|
||||
const handleToggleDone = (task: TaskWithTablo) => {
|
||||
const newStatus: TaskStatus =
|
||||
task.status === "done" ? "todo" : "done";
|
||||
updateTask.mutate({ id: task.id, status: newStatus });
|
||||
};
|
||||
|
||||
if (myTasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between px-4 py-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t("dashboard.taskList.title")}
|
||||
</h2>
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
onClick={() => setIsTaskModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>{t("dashboard.taskList.addTask")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[600px]">
|
||||
{myTasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggleDone={handleToggleDone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={() => setIsTaskModalOpen(false)}
|
||||
tablos={tablos}
|
||||
allowTabloSelection
|
||||
initialStatus="todo"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,7 +29,19 @@ import { useCreateEvents, useEvent, useUpdateEvent } from "../hooks/events";
|
|||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
|
||||
export const EventModal = ({
|
||||
mode,
|
||||
isOpen,
|
||||
onClose: onCloseProp,
|
||||
defaultTabloId,
|
||||
defaultDate,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
defaultTabloId?: string;
|
||||
defaultDate?: Date;
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation("components");
|
||||
const { event_id } = useParams();
|
||||
const { data: event } = useEvent(event_id as string);
|
||||
|
|
@ -37,17 +49,24 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
|
|||
const user = useUser();
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
const [searchParams] = useSearchParams();
|
||||
const tablo_id = searchParams.get("tablo_id");
|
||||
const dateFromParams = searchParams.get("date");
|
||||
const date = dateFromParams ? new Date(dateFromParams) : new Date();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// When used standalone (isOpen prop provided), ignore URL params and use props instead
|
||||
const isStandalone = isOpen !== undefined;
|
||||
const tablo_id = isStandalone ? (defaultTabloId ?? "") : (searchParams.get("tablo_id") ?? "");
|
||||
const dateFromParams = isStandalone ? null : searchParams.get("date");
|
||||
const date = defaultDate ?? (dateFromParams ? new Date(dateFromParams) : new Date());
|
||||
|
||||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
const createEvents = useCreateEvents();
|
||||
const updateEvent = useUpdateEvent();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onClose = () => {
|
||||
navigate(-1);
|
||||
if (onCloseProp) {
|
||||
onCloseProp();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
// Get the local date string without timezone conversion
|
||||
|
|
@ -93,7 +112,7 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
|
|||
}, [mode, event]);
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<Dialog open={isStandalone ? isOpen : true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export const ExceptionModal = ({
|
|||
}) => {
|
||||
const { t } = useTranslation("components");
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(formSchema as any),
|
||||
defaultValues: {
|
||||
exceptionType: "day",
|
||||
exceptionDate: new Date(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom";
|
|||
import { twMerge } from "tailwind-merge";
|
||||
import { SideNavigation } from "./NavigationBar";
|
||||
import { OnboardingModal } from "./OnboardingModal";
|
||||
import { TopBar } from "./TopBar";
|
||||
|
||||
const ONBOARDING_STORAGE_KEY = "xtablo-onboarding-completed";
|
||||
|
||||
|
|
@ -50,9 +51,12 @@ export function Layout() {
|
|||
<SideNavigation isMobileMenuOpen={isMobileMenuOpen} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,17 @@ import {
|
|||
CalendarCheckIcon,
|
||||
CalendarIcon,
|
||||
Circle,
|
||||
Compass,
|
||||
ConstructionIcon,
|
||||
CreditCard,
|
||||
// FileTextIcon, // Notes feature temporarily hidden
|
||||
Flame,
|
||||
FolderIcon,
|
||||
Gem,
|
||||
Heart,
|
||||
Kanban,
|
||||
LayersIcon,
|
||||
Leaf,
|
||||
ListTodo,
|
||||
LogOutIcon,
|
||||
MessageCircleIcon,
|
||||
|
|
@ -31,7 +38,12 @@ import {
|
|||
SettingsIcon,
|
||||
Sparkles,
|
||||
SquareKanban,
|
||||
Star,
|
||||
Sun,
|
||||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useState } from "react";
|
||||
import { Separator } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -42,7 +54,6 @@ import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
|
|||
import { isProd, isStaging } from "../lib/env";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { getXtabloIcon } from "../utils/iconHelpers";
|
||||
import { NotificationPanel } from "./NotificationPanel";
|
||||
import { ThemeSwitcher } from "./ThemeSwitcher";
|
||||
|
||||
type NavLinkItem = {
|
||||
|
|
@ -56,7 +67,7 @@ function NavLink({ isActive, children }: NavLinkProps) {
|
|||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"group w-full gap-x-3 overflow-hidden px-2.5 py-1.5 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[data-ui=icon]:not([class*=size-])]:size-4.5",
|
||||
"group w-full gap-x-3 overflow-hidden px-2.5 py-2 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[data-ui=icon]:not([class*=size-])]:size-5",
|
||||
"*:data-[ui=notification-badge]:bg-navbar-darker",
|
||||
"*:data-[ui=notification-badge]:rounded-md",
|
||||
"*:data-[ui=notification-badge]:top-1/2",
|
||||
|
|
@ -67,8 +78,8 @@ function NavLink({ isActive, children }: NavLinkProps) {
|
|||
"*:data-[ui=notification-badge]:text-xs/6",
|
||||
"*:data-[ui=notification-badge]:font-semibold",
|
||||
isActive
|
||||
? "bg-navbar-darker font-semibold text-white *:data-[ui=notification-badge]:bg-transparent"
|
||||
: ["font-medium", "text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
|
||||
? "bg-purple-100 dark:bg-purple-900/30 font-semibold text-[#804EEC] dark:text-purple-300 *:data-[ui=notification-badge]:bg-transparent"
|
||||
: ["font-medium", "text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -82,13 +93,13 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
const { t } = useTranslation("navigation");
|
||||
|
||||
const MenuSeparator = () => {
|
||||
return <DropdownMenuSeparator className="bg-gray-500!" />;
|
||||
return <DropdownMenuSeparator className="bg-gray-300 dark:bg-gray-500!" />;
|
||||
};
|
||||
|
||||
const itemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-gray-200/90 focus:bg-gray-500/80 focus:text-white",
|
||||
default: "text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white",
|
||||
destructive: "text-red-500/80 focus:bg-red-500/80 focus:text-white",
|
||||
},
|
||||
},
|
||||
|
|
@ -134,10 +145,10 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col items-start">
|
||||
<TypographyMuted className="text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
|
||||
<TypographyMuted className="text-gray-700 dark:text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
|
||||
{user.first_name} {user.last_name}
|
||||
</TypographyMuted>
|
||||
<TypographyMuted className="text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
|
||||
<TypographyMuted className="text-gray-500 dark:text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
|
||||
{user.email}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
|
@ -145,7 +156,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 bg-navbar-background border-gray-600/50 p-1 rounded-lg text-white"
|
||||
className="min-w-56 bg-navbar-background border-gray-300 dark:border-gray-600/50 p-1 rounded-lg text-gray-900 dark:text-white"
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={-8}
|
||||
|
|
@ -153,7 +164,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<div className="flex gap-2 p-1">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
|
||||
<AvatarFallback className="bg-gray-700 text-white">
|
||||
<AvatarFallback className="bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-white">
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
<AvatarBadge className="size-3">
|
||||
|
|
@ -161,10 +172,10 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</AvatarBadge>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<TypographyMuted className="font-bold text-gray-100 text-sm truncate">
|
||||
<TypographyMuted className="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">
|
||||
{user.name}
|
||||
</TypographyMuted>
|
||||
<TypographyMuted className="text-gray-300 text-xs truncate">
|
||||
<TypographyMuted className="text-gray-500 dark:text-gray-300 text-xs truncate">
|
||||
{user.email}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
|
@ -246,7 +257,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
/>
|
||||
<h1
|
||||
className={twMerge(
|
||||
"text-lg font-bold transition-all duration-300 text-white whitespace-nowrap",
|
||||
"text-lg font-bold transition-all duration-300 text-gray-900 dark:text-white whitespace-nowrap",
|
||||
isCollapsed ? "w-0 h-0 opacity-0" : "w-auto opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -263,7 +274,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
className={twMerge(
|
||||
isCollapsed ? "relative" : "absolute top-2 right-2",
|
||||
"size-5 p-1",
|
||||
"text-gray-300 hover:text-white",
|
||||
"text-gray-500 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white",
|
||||
"transition-all duration-300",
|
||||
"bg-navbar-background",
|
||||
"rounded-full shadow-md",
|
||||
|
|
@ -282,13 +293,80 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
isCollapsed ? "pl-2.5 pr-3.5" : ""
|
||||
)}
|
||||
>
|
||||
<NotificationPanel isCollapsed={isCollapsed} />
|
||||
<UserMenuPopover isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function getTabloIconColor(_color: string | null | undefined): string {
|
||||
return "text-gray-700 dark:text-white";
|
||||
}
|
||||
|
||||
function RecentProjectsSection() {
|
||||
const { t } = useTranslation("navigation");
|
||||
const location = useLocation();
|
||||
const { data: tablos } = useTablosList();
|
||||
const recentTablos = (tablos ?? []).slice(0, 4);
|
||||
|
||||
if (recentTablos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<Separator className="border-gray-300 dark:border-gray-300/20 mb-3" />
|
||||
<div className="px-2 mb-2">
|
||||
<span className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("projects", "Projects")}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{recentTablos.map((tablo) => {
|
||||
const isActive = location.pathname === `/tablos/${tablo.id}`;
|
||||
const TabloIcon = getTabloIcon(tablo.color);
|
||||
const iconColor = getTabloIconColor(tablo.color);
|
||||
return (
|
||||
<li key={tablo.id}>
|
||||
<RouterLink
|
||||
to={`/tablos/${tablo.id}`}
|
||||
className={twMerge(
|
||||
"flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-purple-100 dark:bg-purple-900/30 text-[#804EEC] dark:text-purple-300 font-semibold"
|
||||
: "text-gray-500 dark:text-gray-300/90 hover:bg-navbar-darker hover:text-gray-900 dark:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="w-6 h-6 rounded-full shrink-0 flex items-center justify-center border border-gray-400 dark:border-gray-500/50"
|
||||
style={{ backgroundColor: tablo.color ?? "#6b7280" }}
|
||||
>
|
||||
<TabloIcon className={twMerge("w-3.5 h-3.5", iconColor)} />
|
||||
</span>
|
||||
<span className="truncate flex-1">{tablo.name}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
const location = useLocation();
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
|
|
@ -322,7 +400,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
? [
|
||||
{
|
||||
path: "/",
|
||||
label: t("projects"),
|
||||
label: t("home"),
|
||||
icon: <PanelsTopLeft className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
|
|
@ -334,7 +412,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
: [
|
||||
{
|
||||
path: "/",
|
||||
label: t("projects"),
|
||||
label: t("home"),
|
||||
icon: <PanelsTopLeft className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
|
|
@ -355,6 +433,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
label: t("tasks"),
|
||||
icon: <ListTodo className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
path: "/tablos",
|
||||
label: t("tablos"),
|
||||
icon: <LayersIcon className="w-5 h-5" />,
|
||||
},
|
||||
{ isHorizontalBar: true },
|
||||
{
|
||||
path: "/planning",
|
||||
|
|
@ -366,6 +449,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
label: t("discussions"),
|
||||
icon: <MessageCircleIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
path: "/files",
|
||||
label: t("files", "Fichiers"),
|
||||
icon: <FolderIcon className="w-5 h-5" />,
|
||||
},
|
||||
// Notes feature temporarily hidden
|
||||
// {
|
||||
// path: "/notes",
|
||||
|
|
@ -380,7 +468,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
if ("isHorizontalBar" in item) {
|
||||
return (
|
||||
<li key={`horizontal-bar-${index}`} className="my-2">
|
||||
<Separator className="border-gray-300/20" />
|
||||
<Separator className="border-gray-300 dark:border-gray-300/20" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -396,12 +484,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
className="w-full"
|
||||
aria-label={isCollapsed ? label : undefined}
|
||||
>
|
||||
<div className={twMerge("flex items-center gap-x-2", isCollapsed ? "" : "pl-2")}>
|
||||
{icon}
|
||||
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
|
||||
<span className="[&>svg]:w-6 [&>svg]:h-6">{icon}</span>
|
||||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-sm transition-all duration-300 font-normal",
|
||||
isActive ? "text-white" : "text-gray-300/90",
|
||||
"text-base transition-all duration-300 font-normal",
|
||||
isActive ? "text-[#804EEC] dark:text-purple-300" : "text-gray-500 dark:text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -414,6 +502,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
) : null;
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Recent projects section */}
|
||||
{!isCollapsed && <RecentProjectsSection />}
|
||||
|
||||
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
|
||||
{/* Trial upsell message */}
|
||||
{shouldShowTrialUpsell && !isCollapsed && (
|
||||
|
|
@ -497,7 +589,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
Plan Freemium
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 text-blue-700 dark:text-blue-300">
|
||||
Passer au plan Starter pour profiter de tablos illimités.
|
||||
Passer au plan Starter pour profiter de projets illimités.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -555,12 +647,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
className="w-full"
|
||||
aria-label={isCollapsed ? "Feedback" : undefined}
|
||||
>
|
||||
<div className={twMerge("flex items-center gap-x-2", isCollapsed ? "" : "pl-2")}>
|
||||
<SendIcon className="w-5 h-5" aria-hidden="true" />
|
||||
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
|
||||
<span className="[&>svg]:w-6 [&>svg]:h-6"><SendIcon aria-hidden="true" /></span>
|
||||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-sm transition-all duration-300 font-normal",
|
||||
location.pathname === "/feedback" ? "text-white" : "text-gray-300/90",
|
||||
"text-base transition-all duration-300 font-normal",
|
||||
location.pathname === "/feedback" ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -569,9 +661,9 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</div>
|
||||
</RouterLink>
|
||||
</NavLink>
|
||||
<li className="my-2">
|
||||
<Separator className="border-gray-300/20" />
|
||||
</li>
|
||||
</li>
|
||||
<li className="my-2">
|
||||
<Separator className="border-gray-300 dark:border-gray-300/20" />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
|||
165
apps/main/src/components/ProjectCard.tsx
Normal file
165
apps/main/src/components/ProjectCard.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { cn } from "@xtablo/shared";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Calendar, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type StatusConfig = {
|
||||
label: string;
|
||||
badgeClass: string;
|
||||
progressColor: string;
|
||||
};
|
||||
|
||||
function useStatusConfig(status: string): StatusConfig {
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return {
|
||||
label: t("tablo.status.todo"),
|
||||
badgeClass:
|
||||
"bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
|
||||
progressColor: "bg-blue-500",
|
||||
};
|
||||
case "in_progress":
|
||||
return {
|
||||
label: t("tablo.status.inProgress"),
|
||||
badgeClass:
|
||||
"bg-yellow-50 text-yellow-600 border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-800",
|
||||
progressColor: "bg-purple-500",
|
||||
};
|
||||
case "done":
|
||||
return {
|
||||
label: t("tablo.status.done"),
|
||||
badgeClass:
|
||||
"bg-green-50 text-green-600 border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
|
||||
progressColor: "bg-green-500",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: t("tablo.status.todo"),
|
||||
badgeClass:
|
||||
"bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
|
||||
progressColor: "bg-blue-500",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getProgressFromStatus(status: string): number {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return 0;
|
||||
case "in_progress":
|
||||
return 50;
|
||||
case "done":
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectCardProps {
|
||||
tablo: UserTablo;
|
||||
onClick?: (tabloId: string) => void;
|
||||
onMenuClick?: (tabloId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectCard({
|
||||
tablo,
|
||||
onClick,
|
||||
onMenuClick,
|
||||
className,
|
||||
}: ProjectCardProps) {
|
||||
const { t } = useTranslation("pages");
|
||||
const statusConfig = useStatusConfig(tablo.status);
|
||||
const progress = getProgressFromStatus(tablo.status);
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(tablo.created_at));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white dark:bg-gray-800 rounded-2xl p-4 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onClick?.(tablo.id)}
|
||||
>
|
||||
{/* Status + Menu */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-xs font-medium border",
|
||||
statusConfig.badgeClass,
|
||||
)}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<button
|
||||
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMenuClick?.(tablo.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail + Name */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center shrink-0 overflow-hidden",
|
||||
!tablo.image && (tablo.color || "bg-gray-400"),
|
||||
)}
|
||||
>
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white font-bold text-lg">
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 flex-1 truncate">
|
||||
{tablo.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t("tablo.card.progress")}:
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full transition-all",
|
||||
statusConfig.progressColor,
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
apps/main/src/components/ProjectCardList.tsx
Normal file
78
apps/main/src/components/ProjectCardList.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { ChevronDown, ChevronRight, ChevronUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
|
||||
const DEFAULT_VISIBLE = 6;
|
||||
|
||||
export interface ProjectCardListProps {
|
||||
tablos: UserTablo[];
|
||||
onTabloClick?: (tabloId: string) => void;
|
||||
onTabloMenuClick?: (tabloId: string) => void;
|
||||
onSeeAllClick?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectCardList({
|
||||
tablos,
|
||||
onTabloClick,
|
||||
onTabloMenuClick,
|
||||
onSeeAllClick,
|
||||
}: ProjectCardListProps) {
|
||||
const { t } = useTranslation("pages");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasMore = tablos.length > DEFAULT_VISIBLE;
|
||||
const visibleTablos = expanded ? tablos : tablos.slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t("tablo.projectList.title")}
|
||||
</h2>
|
||||
{onSeeAllClick && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-purple-600 hover:text-purple-500 dark:text-purple-400 dark:hover:text-purple-300 font-medium"
|
||||
onClick={onSeeAllClick}
|
||||
>
|
||||
{t("tablo.projectList.seeAll")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{visibleTablos.map((tablo) => (
|
||||
<ProjectCard
|
||||
key={tablo.id}
|
||||
tablo={tablo}
|
||||
onClick={onTabloClick}
|
||||
onMenuClick={onTabloMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-purple-600 hover:text-purple-500 dark:text-purple-400 dark:hover:text-purple-300 font-medium text-sm"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
{t("tablo.projectList.showLess")}
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("tablo.projectList.showAll", {
|
||||
count: tablos.length - DEFAULT_VISIBLE,
|
||||
})}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
{t("tablo:events.description")}
|
||||
</TypographyMuted>
|
||||
{!isReadOnly && (
|
||||
<Button onClick={handleCreateEvent} className="flex items-center gap-2 mt-4">
|
||||
<Button onClick={handleCreateEvent} className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("tablo:events.createEvent")}
|
||||
</Button>
|
||||
|
|
@ -77,7 +77,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
<div className="bg-card rounded-lg border border-border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#804EEC]"></div>
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Chargement des événements...
|
||||
</span>
|
||||
|
|
@ -111,13 +111,13 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
<div key={event.event_id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Date Badge */}
|
||||
<div className="shrink-0 w-16 h-16 bg-primary/10 dark:bg-primary/20 rounded-lg flex flex-col items-center justify-center">
|
||||
<span className="text-xs font-medium text-primary uppercase">
|
||||
<div className="shrink-0 w-14 h-14 bg-[#F4F3FF] dark:bg-purple-900/20 rounded-[8px] flex flex-col items-center justify-center">
|
||||
<span className="text-[10px] font-semibold text-[#7F56D9] dark:text-purple-400 uppercase tracking-wide leading-none">
|
||||
{new Date(event.start_date).toLocaleDateString("fr-FR", {
|
||||
month: "short",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
<span className="text-xl font-bold text-[#7F56D9] dark:text-purple-400 leading-none mt-0.5">
|
||||
{new Date(event.start_date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -172,7 +172,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
Aucun événement à venir pour ce tablo
|
||||
</p>
|
||||
{!isReadOnly && (
|
||||
<Button onClick={handleCreateEvent} variant="outline" className="mt-4">
|
||||
<Button onClick={handleCreateEvent} className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer le premier événement
|
||||
</Button>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
409
apps/main/src/components/TopBar.tsx
Normal file
409
apps/main/src/components/TopBar.tsx
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import type { Database } from "@xtablo/shared-types";
|
||||
import { Badge } from "@xtablo/ui/components/badge";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@xtablo/ui/components/dropdown-menu";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@xtablo/ui/components/avatar";
|
||||
import {
|
||||
TypographyMuted,
|
||||
TypographySmall,
|
||||
} from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
BellIcon,
|
||||
CalendarCheckIcon,
|
||||
CalendarIcon,
|
||||
CheckCheckIcon,
|
||||
FileTextIcon,
|
||||
KanbanIcon,
|
||||
LayoutDashboardIcon,
|
||||
LogOutIcon,
|
||||
MailIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useLogout } from "../hooks/auth";
|
||||
import {
|
||||
useNotifications,
|
||||
useNotificationsSubscription,
|
||||
} from "../hooks/notifications";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
type Notification = Database["public"]["Tables"]["notifications"]["Row"];
|
||||
|
||||
function getNotificationIcon(entityType: string) {
|
||||
switch (entityType) {
|
||||
case "tablos":
|
||||
return <LayoutDashboardIcon className="w-4 h-4" />;
|
||||
case "tasks":
|
||||
return <KanbanIcon className="w-4 h-4" />;
|
||||
case "events":
|
||||
return <CalendarIcon className="w-4 h-4" />;
|
||||
case "notes":
|
||||
return <FileTextIcon className="w-4 h-4" />;
|
||||
case "tablo_access":
|
||||
return <UserPlusIcon className="w-4 h-4" />;
|
||||
case "tablo_invites":
|
||||
return <MailIcon className="w-4 h-4" />;
|
||||
default:
|
||||
return <BellIcon className="w-4 h-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationLink(notification: Notification): string {
|
||||
const { entity_type, entity_id, metadata } = notification;
|
||||
switch (entity_type) {
|
||||
case "tablos":
|
||||
return `/tablos/${entity_id}`;
|
||||
case "tasks":
|
||||
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
|
||||
return `/tablos/${metadata.tablo_id}`;
|
||||
}
|
||||
return "/";
|
||||
case "events":
|
||||
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
|
||||
return `/tablos/${metadata.tablo_id}`;
|
||||
}
|
||||
return "/planning?tab=events";
|
||||
case "notes":
|
||||
return `/notes/${entity_id}`;
|
||||
case "tablo_access":
|
||||
case "tablo_invites":
|
||||
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
|
||||
return `/tablos/${metadata.tablo_id}`;
|
||||
}
|
||||
return "/";
|
||||
default:
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return "Just now";
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) return `${diffInHours}h ago`;
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) return `${diffInDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onMarkAsRead,
|
||||
}: {
|
||||
notification: Notification;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const link = getNotificationLink(notification);
|
||||
|
||||
const getMessage = () => {
|
||||
const locale = i18n.language.startsWith("fr") ? "fr" : "en";
|
||||
return (
|
||||
(notification.message as Record<string, string>)[locale] ||
|
||||
(notification.message as Record<string, string>)["en"] ||
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={link} onClick={() => onMarkAsRead(notification.id)}>
|
||||
<DropdownMenuItem className="cursor-pointer p-3 focus:bg-gray-100 hover:bg-gray-100 text-gray-800">
|
||||
<div className="flex gap-3 w-full">
|
||||
<div className="shrink-0 mt-1">
|
||||
<div className="p-2 rounded-full bg-blue-100 text-blue-600">
|
||||
{getNotificationIcon(notification.entity_type)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<TypographySmall className="font-medium text-gray-900 line-clamp-2">
|
||||
{getMessage()}
|
||||
</TypographySmall>
|
||||
<TypographyMuted className="text-xs mt-1 text-gray-500">
|
||||
{formatRelativeTime(notification.created_at)}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-200 text-gray-500 hover:text-gray-900"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMarkAsRead(notification.id);
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationDropdown() {
|
||||
const { t } = useTranslation("navigation");
|
||||
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } =
|
||||
useNotifications();
|
||||
const { setupSubscription } = useNotificationsSubscription();
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = setupSubscription();
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative w-10 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"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<BellIcon className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 rounded-full bg-red-500 text-white text-[10px] font-semibold leading-none">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-96 bg-white border border-[#EAECF0] p-1 rounded-lg text-gray-900 shadow-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypographySmall className="font-semibold text-gray-900">
|
||||
{t("notifications.title", "Notifications")}
|
||||
</TypographySmall>
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
||||
onClick={() => markAllAsRead()}
|
||||
>
|
||||
<CheckCheckIcon className="h-3 w-3 mr-1" />
|
||||
{t("notifications.markAllRead", "Mark all read")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<TypographyMuted className="text-sm text-gray-500">
|
||||
{t("notifications.loading", "Loading...")}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div className="p-3 rounded-full bg-gray-100 mb-3">
|
||||
<BellIcon className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<TypographySmall className="font-medium text-gray-900 mb-1">
|
||||
{t("notifications.noNotifications", "No new notifications")}
|
||||
</TypographySmall>
|
||||
<TypographyMuted className="text-xs text-gray-500">
|
||||
{t("notifications.allCaughtUp", "You're all caught up!")}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{notifications.map((notification) => (
|
||||
<div key={notification.id} className="group">
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onMarkAsRead={markAsRead}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDropdown() {
|
||||
const { t } = useTranslation("navigation");
|
||||
const user = useUser();
|
||||
const { mutate: logout } = useLogout();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-2 p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-[8px]"
|
||||
aria-label="Profile menu"
|
||||
>
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={user.avatar_url ?? undefined} alt="Avatar" />
|
||||
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 bg-white dark:bg-gray-900 border border-[#EAECF0] dark:border-gray-700 p-1 rounded-lg text-gray-900 dark:text-gray-100 shadow-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="flex gap-2 p-2">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage
|
||||
src={user.avatar_url ?? undefined}
|
||||
alt={user.name ?? "User avatar"}
|
||||
/>
|
||||
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<TypographySmall className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{user.name}
|
||||
</TypographySmall>
|
||||
<TypographyMuted className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{user.email}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator className="bg-gray-100 dark:bg-gray-700" />
|
||||
|
||||
<Link to="/settings">
|
||||
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
{t("userMenu.settings")}
|
||||
</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" />
|
||||
{t("userMenu.availabilities")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuSeparator className="bg-gray-100 dark:bg-gray-700" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2 text-red-600 dark:text-red-400 focus:bg-red-50 dark:focus:bg-red-900/30 focus:text-red-600 dark:focus:text-red-300"
|
||||
onClick={() => logout()}
|
||||
>
|
||||
<LogOutIcon className="w-4 h-4" />
|
||||
{t("userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const SEARCH_ROUTES = ["/tablos", "/", "/tasks", "/files"];
|
||||
|
||||
export function TopBar() {
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const searchQuery = searchParams.get("q") ?? "";
|
||||
|
||||
const isSearchRoute = SEARCH_ROUTES.includes(location.pathname);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (e.target.value) {
|
||||
newParams.set("q", e.target.value);
|
||||
} else {
|
||||
newParams.delete("q");
|
||||
}
|
||||
setSearchParams(newParams, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-[75px] flex items-center justify-between px-6 gap-4 border-b border-[#EAECF0] dark:border-gray-700 bg-navbar-background shrink-0">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={isSearchRoute ? searchQuery : ""}
|
||||
onChange={isSearchRoute ? handleSearchChange : undefined}
|
||||
readOnly={!isSearchRoute}
|
||||
className="w-full pl-9 pr-4 py-2 bg-transparent border border-[#EAECF0] dark:border-gray-700 rounded-lg text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary dark:bg-gray-800/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{location.pathname === "/planning" && (
|
||||
<div className="hidden md:flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-0.5">
|
||||
<Link
|
||||
to="/planning"
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
new URLSearchParams(location.search).get("tab") !== "events"
|
||||
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
|
||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
Calendrier
|
||||
</Link>
|
||||
<Link
|
||||
to="/planning?tab=events"
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
new URLSearchParams(location.search).get("tab") === "events"
|
||||
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
|
||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
<CalendarCheckIcon className="w-4 h-4" />
|
||||
Événements
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<NotificationDropdown />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
569
apps/main/src/components/gantt/GanttChart.tsx
Normal file
569
apps/main/src/components/gantt/GanttChart.tsx
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@xtablo/ui/components/dropdown-menu";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Compass,
|
||||
Flame,
|
||||
FolderIcon,
|
||||
Gem,
|
||||
Heart,
|
||||
Leaf,
|
||||
MapIcon,
|
||||
Sparkles,
|
||||
Star,
|
||||
Sun,
|
||||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type GanttTask = KanbanTask & {
|
||||
tablos?: { id: string; name: string; color: string | null } | null;
|
||||
};
|
||||
|
||||
interface GanttChartProps {
|
||||
tasks: GanttTask[];
|
||||
isLoading: boolean;
|
||||
onDateClick?: (date: Date) => void;
|
||||
onTaskStatusChange?: (taskId: string, status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
string,
|
||||
{ bg: string; border: string; dot: string; label: string }
|
||||
> = {
|
||||
todo: {
|
||||
bg: "bg-[#EFF8FF]",
|
||||
border: "border-l-[#3B82F6]",
|
||||
dot: "bg-[#1570EF]",
|
||||
label: "À faire",
|
||||
},
|
||||
in_progress: {
|
||||
bg: "bg-[#FFF4E2]",
|
||||
border: "border-l-[#F1A62D]",
|
||||
dot: "bg-[#DB9729]",
|
||||
label: "En cours",
|
||||
},
|
||||
in_review: {
|
||||
bg: "bg-[#F4F3FF]",
|
||||
border: "border-l-[#804EEC]",
|
||||
dot: "bg-[#804EEC]",
|
||||
label: "Vérification",
|
||||
},
|
||||
done: {
|
||||
bg: "bg-[#EDFCF2]",
|
||||
border: "border-l-[#16B364]",
|
||||
dot: "bg-[#16B364]",
|
||||
label: "Terminé",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_TEXT_COLORS: Record<string, string> = {
|
||||
todo: "text-[#1570EF]",
|
||||
in_progress: "text-[#DB9729]",
|
||||
in_review: "text-[#804EEC]",
|
||||
done: "text-[#16B364]",
|
||||
};
|
||||
|
||||
const ROADMAP_TASK_STATUSES: TaskStatus[] = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
];
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500":
|
||||
return Zap;
|
||||
case "bg-green-500":
|
||||
return Leaf;
|
||||
case "bg-purple-500":
|
||||
return Gem;
|
||||
case "bg-red-500":
|
||||
return Flame;
|
||||
case "bg-yellow-500":
|
||||
return Star;
|
||||
case "bg-indigo-500":
|
||||
return Compass;
|
||||
case "bg-pink-500":
|
||||
return Heart;
|
||||
case "bg-teal-500":
|
||||
return Waves;
|
||||
case "bg-orange-500":
|
||||
return Sun;
|
||||
case "bg-cyan-500":
|
||||
return Sparkles;
|
||||
default:
|
||||
return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function getMonday(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
d.setDate(diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addDays(date: Date, n: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function formatShortDay(date: Date): string {
|
||||
return date
|
||||
.toLocaleDateString("fr-FR", { weekday: "short" })
|
||||
.replace(".", "");
|
||||
}
|
||||
|
||||
function formatDateRange(start: Date, end: Date): string {
|
||||
const opts: Intl.DateTimeFormatOptions = { month: "long", day: "numeric" };
|
||||
const startStr = start.toLocaleDateString("fr-FR", opts);
|
||||
const endStr = end.toLocaleDateString("fr-FR", opts);
|
||||
return `${startStr} - ${endStr}`;
|
||||
}
|
||||
|
||||
type ViewMode = "weekly" | "biweekly";
|
||||
|
||||
const VIEW_CONFIG = {
|
||||
weekly: { days: 7, label: "Semaine", cardHeight: 130, stepWeeks: 1 },
|
||||
biweekly: { days: 14, label: "2 semaines", cardHeight: 100, stepWeeks: 2 },
|
||||
} as const;
|
||||
|
||||
const CARD_GAP = 10;
|
||||
const CARD_TOP_OFFSET = 20;
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function GanttChart({
|
||||
tasks,
|
||||
isLoading,
|
||||
onDateClick,
|
||||
onTaskStatusChange,
|
||||
}: GanttChartProps) {
|
||||
const [weekOffset, setWeekOffset] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("weekly");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(992);
|
||||
|
||||
const config = VIEW_CONFIG[viewMode];
|
||||
const numDays = config.days;
|
||||
const cardHeight = config.cardHeight;
|
||||
|
||||
// Measure container width
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(Math.max(entry.contentRect.width, 992));
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colWidth = containerWidth / numDays;
|
||||
|
||||
// Compute days
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const periodStart = useMemo(
|
||||
() => addDays(getMonday(today), weekOffset * 7),
|
||||
[today, weekOffset],
|
||||
);
|
||||
const periodEnd = useMemo(
|
||||
() => addDays(periodStart, numDays - 1),
|
||||
[periodStart, numDays],
|
||||
);
|
||||
|
||||
const days = useMemo(
|
||||
() => Array.from({ length: numDays }, (_, i) => addDays(periodStart, i)),
|
||||
[periodStart, numDays],
|
||||
);
|
||||
|
||||
// Filter tasks with due_date in this period
|
||||
const visibleTasks = useMemo(() => {
|
||||
const start = periodStart.getTime();
|
||||
const end = addDays(periodStart, numDays).getTime();
|
||||
return tasks.filter((t) => {
|
||||
if (!t.due_date) return false;
|
||||
const d = new Date(t.due_date).getTime();
|
||||
return d >= start && d < end;
|
||||
});
|
||||
}, [tasks, periodStart, numDays]);
|
||||
|
||||
// Position tasks: group by day column, stack vertically
|
||||
const positionedTasks = useMemo(() => {
|
||||
const columnSlots: number[][] = Array.from({ length: numDays }, () => []);
|
||||
|
||||
return visibleTasks
|
||||
.map((task) => {
|
||||
const dueDate = new Date(task.due_date!);
|
||||
const dayIndex = days.findIndex((d) => isSameDay(d, dueDate));
|
||||
if (dayIndex === -1) return null;
|
||||
|
||||
// Find first available row in this column
|
||||
const slots = columnSlots[dayIndex];
|
||||
let row = 0;
|
||||
while (slots.includes(row)) row++;
|
||||
slots.push(row);
|
||||
|
||||
return {
|
||||
task,
|
||||
dayIndex,
|
||||
row,
|
||||
left: dayIndex * colWidth + 4,
|
||||
top: CARD_TOP_OFFSET + row * (cardHeight + CARD_GAP),
|
||||
width: colWidth - 8,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
task: GanttTask;
|
||||
dayIndex: number;
|
||||
row: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}>;
|
||||
}, [visibleTasks, days, colWidth, numDays, cardHeight]);
|
||||
|
||||
// Compute chart height
|
||||
const maxRow = positionedTasks.reduce((max, pt) => Math.max(max, pt.row), 0);
|
||||
const chartHeight = Math.max(
|
||||
400,
|
||||
(maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20,
|
||||
);
|
||||
|
||||
// Today indicator position
|
||||
const todayIndex = days.findIndex((d) => isSameDay(d, today));
|
||||
const todayInRange = todayIndex >= 0;
|
||||
|
||||
const handleViewModeChange = (mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
setWeekOffset(0);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Navigation bar */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-lg"
|
||||
onClick={() => setWeekOffset((w) => w - config.stepWeeks)}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="px-4 py-2 bg-card dark:bg-gray-900 border border-border dark:border-gray-700 rounded-lg min-w-[200px] text-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatDateRange(periodStart, periodEnd)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-lg"
|
||||
onClick={() => setWeekOffset((w) => w + config.stepWeeks)}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2 rounded-lg">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>{config.label}</span>
|
||||
<ChevronRightIcon className="h-4 w-4 rotate-90" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewModeChange("weekly")}>
|
||||
Semaine
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleViewModeChange("biweekly")}>
|
||||
2 semaines
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setWeekOffset(0)}>
|
||||
Aller à aujourd'hui
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Gantt chart */}
|
||||
<div
|
||||
className="bg-card dark:bg-gray-900 rounded-xl border border-border dark:border-gray-700 overflow-hidden shadow-sm"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<div style={{ minWidth: 992 }}>
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-border">
|
||||
{days.map((day, i) => {
|
||||
const isToday = isSameDay(day, today);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onDateClick?.(new Date(day))}
|
||||
className="flex flex-col items-center py-3 relative transition-colors hover:bg-accent/40"
|
||||
style={{ width: colWidth }}
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
isToday ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatShortDay(day)} {day.getDate()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid body */}
|
||||
<div className="relative" style={{ height: chartHeight }}>
|
||||
{/* Clickable day columns */}
|
||||
<div className="absolute inset-0 flex z-0">
|
||||
{days.map((day, i) => (
|
||||
<button
|
||||
key={`click-${i}`}
|
||||
type="button"
|
||||
onClick={() => onDateClick?.(new Date(day))}
|
||||
className="h-full transition-colors hover:bg-accent/20"
|
||||
style={{ width: colWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Vertical grid lines */}
|
||||
<div className="absolute inset-0 flex pointer-events-none z-10">
|
||||
{days.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={twMerge(
|
||||
"border-border",
|
||||
i < numDays - 1 ? "border-r" : "",
|
||||
)}
|
||||
style={{ width: colWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Today indicator */}
|
||||
{todayInRange && (
|
||||
<div
|
||||
className="absolute z-20 pointer-events-none"
|
||||
style={{
|
||||
left: (todayIndex + 0.5) * colWidth,
|
||||
top: 0,
|
||||
height: chartHeight,
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2">
|
||||
<div className="w-0 h-0 border-l-[8px] border-r-[8px] border-t-[10px] border-l-transparent border-r-transparent border-t-primary" />
|
||||
</div>
|
||||
<div className="w-0.5 h-full bg-primary mx-auto opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task cards */}
|
||||
{positionedTasks.map((pt) => {
|
||||
const status =
|
||||
STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo;
|
||||
const textColor =
|
||||
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ??
|
||||
STATUS_TEXT_COLORS.todo;
|
||||
const TabloIcon = pt.task.tablos
|
||||
? getTabloIcon(pt.task.tablos.color)
|
||||
: null;
|
||||
|
||||
const isCompact = viewMode === "biweekly";
|
||||
const taskCardContent = (
|
||||
<>
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-1.5 bg-white w-fit px-2 py-0.5 rounded-full shadow-sm">
|
||||
<span
|
||||
className={twMerge("w-2 h-2 rounded-full", status.dot)}
|
||||
/>
|
||||
{!isCompact && (
|
||||
<span
|
||||
className={twMerge("text-xs font-medium", textColor)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3
|
||||
className={twMerge(
|
||||
"font-semibold text-gray-900 leading-tight line-clamp-1",
|
||||
isCompact ? "mt-1 text-xs" : "mt-2 text-sm",
|
||||
)}
|
||||
>
|
||||
{pt.task.title}
|
||||
</h3>
|
||||
|
||||
{/* Due date */}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(pt.task.due_date!).toLocaleDateString("fr-FR", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: isCompact ? undefined : "short",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Tablo badge — hidden in compact mode */}
|
||||
{!isCompact && pt.task.tablos && TabloIcon && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-md flex items-center justify-center",
|
||||
pt.task.tablos.color || "bg-gray-400",
|
||||
)}
|
||||
>
|
||||
<TabloIcon className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{pt.task.tablos.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!onTaskStatusChange) {
|
||||
return (
|
||||
<div
|
||||
key={pt.task.id}
|
||||
className={twMerge(
|
||||
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left cursor-default",
|
||||
isCompact ? "p-2" : "p-3",
|
||||
status.bg,
|
||||
status.border,
|
||||
)}
|
||||
style={{
|
||||
left: pt.left,
|
||||
width: pt.width,
|
||||
top: pt.top,
|
||||
minWidth: isCompact ? 80 : 160,
|
||||
}}
|
||||
>
|
||||
{taskCardContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu key={pt.task.id}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={twMerge(
|
||||
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left",
|
||||
isCompact ? "p-2" : "p-3",
|
||||
status.bg,
|
||||
status.border,
|
||||
"cursor-pointer",
|
||||
)}
|
||||
style={{
|
||||
left: pt.left,
|
||||
width: pt.width,
|
||||
top: pt.top,
|
||||
minWidth: isCompact ? 80 : 160,
|
||||
}}
|
||||
>
|
||||
{taskCardContent}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{ROADMAP_TASK_STATUSES.map((nextStatus) => {
|
||||
const isCurrent = nextStatus === pt.task.status;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={nextStatus}
|
||||
disabled={isCurrent}
|
||||
onClick={() =>
|
||||
onTaskStatusChange(pt.task.id, nextStatus)
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"w-2 h-2 rounded-full",
|
||||
STATUS_STYLES[nextStatus]?.dot ?? "bg-gray-400",
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
{STATUS_STYLES[nextStatus]?.label ?? nextStatus}
|
||||
{isCurrent ? " (actuel)" : ""}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state within chart */}
|
||||
{positionedTasks.length === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none z-10">
|
||||
<MapIcon className="w-10 h-10 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucune tâche avec échéance sur cette période
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { KanbanTask } from "@xtablo/shared-types";
|
||||
import { TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { User } from "lucide-react";
|
||||
import { CalendarIcon, User } from "lucide-react";
|
||||
|
||||
interface KanbanTaskCardProps {
|
||||
task: KanbanTask;
|
||||
|
|
@ -8,7 +8,24 @@ interface KanbanTaskCardProps {
|
|||
onClick: () => void;
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function isOverdue(dateStr: string): boolean {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const due = new Date(dateStr);
|
||||
return due < today;
|
||||
}
|
||||
|
||||
export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => {
|
||||
const overdue = task.due_date && task.status !== "done" && isOverdue(task.due_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
|
|
@ -24,6 +41,16 @@ export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProp
|
|||
</TypographyMuted>
|
||||
)}
|
||||
|
||||
{/* Due date */}
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs mb-2 ${overdue ? "text-red-500" : "text-muted-foreground"}`}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>{formatDueDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Pill */}
|
||||
<div className="mb-2">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, TaskStatus } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { DatePicker } from "@xtablo/ui/components/date-picker";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import {
|
||||
|
|
@ -13,10 +15,14 @@ import { Textarea } from "@xtablo/ui/components/textarea";
|
|||
import { TypographyH2 } from "@xtablo/ui/components/typography";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCreateTask, useTask, useUpdateTask, useTabloEtapes } from "../../hooks/tasks";
|
||||
import { useTabloMembers } from "../../hooks/tablos";
|
||||
import {
|
||||
useCreateTask,
|
||||
useTabloEtapes,
|
||||
useTask,
|
||||
useUpdateTask,
|
||||
} from "../../hooks/tasks";
|
||||
import type { TabloMember } from "./types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -28,6 +34,7 @@ interface TaskModalProps {
|
|||
etapes?: Etape[]; // Optional - will be fetched if tabloId is provided
|
||||
tablos?: UserTablo[]; // Optional - for tablo selection when creating
|
||||
allowTabloSelection?: boolean; // Whether to show tablo selector
|
||||
initialDueDate?: Date;
|
||||
}
|
||||
|
||||
export const TaskModal = ({
|
||||
|
|
@ -40,27 +47,35 @@ export const TaskModal = ({
|
|||
etapes: providedEtapes,
|
||||
tablos,
|
||||
allowTabloSelection = false,
|
||||
initialDueDate,
|
||||
}: TaskModalProps) => {
|
||||
const { data: task = null } = useTask(taskId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
|
||||
const [etapeId, setEtapeId] = useState<string>("none");
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(
|
||||
initialTabloId || tablos?.[0]?.id || ""
|
||||
initialTabloId || tablos?.[0]?.id || "",
|
||||
);
|
||||
|
||||
// Determine which tablo to use for fetching data
|
||||
const tabloIdForFetch = allowTabloSelection ? selectedTabloId : initialTabloId || "";
|
||||
const tabloIdForFetch = allowTabloSelection
|
||||
? selectedTabloId
|
||||
: initialTabloId || "";
|
||||
|
||||
// Fetch members and etapes for selected tablo if not provided
|
||||
const { data: fetchedMembers = [] } = useTabloMembers(tabloIdForFetch || "");
|
||||
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
|
||||
const { data: fetchedEtapes = [] } = useTabloEtapes(
|
||||
tabloIdForFetch || undefined,
|
||||
);
|
||||
|
||||
// Use provided or fetched data
|
||||
const members = providedMembers || fetchedMembers;
|
||||
const etapes = providedEtapes || fetchedEtapes;
|
||||
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
|
||||
const currentTabloId = allowTabloSelection
|
||||
? selectedTabloId
|
||||
: initialTabloId || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
|
|
@ -68,6 +83,7 @@ export const TaskModal = ({
|
|||
setDescription(task.description ?? "");
|
||||
setAssigneeId(task.assignee_id ?? "unassigned");
|
||||
setEtapeId(task.parent_task_id ?? "none");
|
||||
setDueDate(task.due_date ? new Date(task.due_date) : undefined);
|
||||
if (!initialTabloId && task.tablo_id) {
|
||||
setSelectedTabloId(task.tablo_id);
|
||||
}
|
||||
|
|
@ -77,20 +93,32 @@ export const TaskModal = ({
|
|||
setDescription("");
|
||||
setAssigneeId("unassigned");
|
||||
setEtapeId("none");
|
||||
setDueDate(initialDueDate ? new Date(initialDueDate) : undefined);
|
||||
if (allowTabloSelection && tablos && tablos.length > 0) {
|
||||
setSelectedTabloId(tablos[0].id);
|
||||
}
|
||||
}
|
||||
}, [task, initialTabloId, allowTabloSelection, tablos]);
|
||||
}, [task, initialTabloId, allowTabloSelection, tablos, initialDueDate]);
|
||||
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
const { mutate: updateTask } = useUpdateTask();
|
||||
|
||||
// Format Date to YYYY-MM-DD string for database storage
|
||||
const formatDateForDb = (date: Date | undefined): string | null => {
|
||||
if (!date) return null;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (!currentTabloId) return; // Need a tablo to create task
|
||||
|
||||
const dueDateValue = formatDateForDb(dueDate);
|
||||
|
||||
if (taskId && task) {
|
||||
updateTask({
|
||||
tablo_id: task.tablo_id,
|
||||
|
|
@ -100,6 +128,7 @@ export const TaskModal = ({
|
|||
assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
|
||||
status: initialStatus,
|
||||
parent_task_id: etapeId !== "none" ? etapeId : null,
|
||||
due_date: dueDateValue,
|
||||
});
|
||||
} else {
|
||||
createTask({
|
||||
|
|
@ -109,6 +138,7 @@ export const TaskModal = ({
|
|||
assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
|
||||
status: initialStatus,
|
||||
parent_task_id: etapeId !== "none" ? etapeId : null,
|
||||
due_date: dueDateValue,
|
||||
});
|
||||
}
|
||||
// Reset form
|
||||
|
|
@ -116,6 +146,7 @@ export const TaskModal = ({
|
|||
setDescription("");
|
||||
setAssigneeId("unassigned");
|
||||
setEtapeId("none");
|
||||
setDueDate(undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -143,7 +174,10 @@ export const TaskModal = ({
|
|||
{allowTabloSelection && !taskId && tablos && tablos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tablo">Tablo *</Label>
|
||||
<Select value={selectedTabloId} onValueChange={setSelectedTabloId}>
|
||||
<Select
|
||||
value={selectedTabloId}
|
||||
onValueChange={setSelectedTabloId}
|
||||
>
|
||||
<SelectTrigger id="tablo" className="w-full">
|
||||
<SelectValue placeholder="Sélectionner un tablo" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -188,22 +222,15 @@ export const TaskModal = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
{/* <div className="space-y-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
|
||||
<SelectTrigger id="type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="task">Task</SelectItem>
|
||||
<SelectItem value="story">Story</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
<SelectItem value="epic">Epic</SelectItem>
|
||||
<SelectItem value="subtask">Subtask</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div> */}
|
||||
{/* Due Date */}
|
||||
<div className="space-y-2">
|
||||
<Label>Échéance</Label>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
placeholder="Choisir une date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -31,6 +31,25 @@ export const toastOptions = {
|
|||
timeout: toastTimeout,
|
||||
};
|
||||
|
||||
export interface AllTablosFileNames {
|
||||
tablos: { tabloId: string; fileNames: string[] }[];
|
||||
}
|
||||
|
||||
// Hook to get file names for all tablos in a single request
|
||||
export function useAllTablosFileNames() {
|
||||
const api = useAuthedApi();
|
||||
return useQuery<AllTablosFileNames>({
|
||||
queryKey: ["all-tablo-files"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get("/api/v1/tablo-data/all-filenames");
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to fetch all tablo files");
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to get all file names for a tablo
|
||||
export function useTabloFileNames(tabloId: string) {
|
||||
const api = useAuthedApi();
|
||||
|
|
@ -140,7 +159,7 @@ export function useCreateTabloFile() {
|
|||
{ tabloId: string; fileName: string; data: FileUploadRequest }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, fileName, data }) => {
|
||||
const response = await api.post(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
|
||||
const response = await api.post(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data);
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to create file");
|
||||
}
|
||||
|
|
@ -185,7 +204,7 @@ export function useUpdateTabloFile() {
|
|||
{ tabloId: string; fileName: string; data: FileUploadRequest }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, fileName, data }) => {
|
||||
const response = await api.put(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
|
||||
const response = await api.put(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data);
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to update file");
|
||||
}
|
||||
|
|
@ -226,7 +245,7 @@ export function useDeleteTabloFile() {
|
|||
|
||||
return useMutation<FileOperationResponse, Error, { tabloId: string; fileName: string }>({
|
||||
mutationFn: async ({ tabloId, fileName }) => {
|
||||
const response = await api.delete(`/api/v1/tablo-data/${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");
|
||||
}
|
||||
|
|
|
|||
191
apps/main/src/hooks/tablo_folders.ts
Normal file
191
apps/main/src/hooks/tablo_folders.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@xtablo/shared";
|
||||
import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { useAuthedApi } from "./auth";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
|
||||
const toastOptions = { timeout: 5000 };
|
||||
|
||||
// Hook to get folders for a tablo
|
||||
export function useTabloFolders(tabloId: string) {
|
||||
const api = useAuthedApi();
|
||||
|
||||
return useQuery<TabloFoldersMetadata>({
|
||||
queryKey: ["tablo-folders", tabloId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/api/v1/tablo-data/${tabloId}/folders`);
|
||||
if (response.status === 200) {
|
||||
return response.data;
|
||||
}
|
||||
return { folders: [], version: 1 };
|
||||
},
|
||||
enabled: !!tabloId,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to create a new folder
|
||||
export function useCreateTabloFolder() {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TabloFolder,
|
||||
Error,
|
||||
{ tabloId: string; name: string; description?: string; createdBy: string }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, name, description }) => {
|
||||
const response = await api.post(`/api/v1/tablo-data/${tabloId}/folders`, {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.data?.error || "Failed to create folder");
|
||||
}
|
||||
|
||||
return response.data.folder;
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Livrable créé",
|
||||
description: `Le livrable "${folder.name}" a été créé avec succès`,
|
||||
type: "success",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: `Échec de la création du livrable: ${error.message}`,
|
||||
type: "error",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onSettled: (_, _err, variables) => {
|
||||
if (variables) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tablo-folders", variables.tabloId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update a folder
|
||||
export function useUpdateTabloFolder() {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TabloFolder,
|
||||
Error,
|
||||
{ tabloId: string; folderId: string; name?: string; description?: string }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, folderId, name, description }) => {
|
||||
const response = await api.put(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`, {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.data?.error || "Failed to update folder");
|
||||
}
|
||||
|
||||
return response.data.folder;
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Livrable mis à jour",
|
||||
description: `Le livrable "${folder.name}" a été mis à jour`,
|
||||
type: "success",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: `Échec de la mise à jour du livrable: ${error.message}`,
|
||||
type: "error",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onSettled: (_, _err, variables) => {
|
||||
if (variables) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tablo-folders", variables.tabloId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to delete a folder
|
||||
export function useDeleteTabloFolder() {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, { tabloId: string; folderId: string; folderName: string }>({
|
||||
mutationFn: async ({ tabloId, folderId }) => {
|
||||
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.data?.error || "Failed to delete folder");
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Livrable supprimé",
|
||||
description: `Le livrable "${variables.folderName}" a été supprimé`,
|
||||
type: "success",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: `Échec de la suppression du livrable: ${error.message}`,
|
||||
type: "error",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onSettled: (_, _err, variables) => {
|
||||
if (variables) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tablo-folders", variables.tabloId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get folder path prefix for a file
|
||||
export const getFolderFilePrefix = (folderId: string) => `${folderId}/`;
|
||||
|
||||
// Helper to extract folder ID from a file name
|
||||
export const extractFolderIdFromFileName = (fileName: string): string | null => {
|
||||
const match = fileName.match(/^(folder-[^/]+)\//);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
// Helper to get the actual file name without folder prefix
|
||||
export const getFileNameWithoutFolder = (fileName: string): string => {
|
||||
const folderId = extractFolderIdFromFileName(fileName);
|
||||
if (folderId) {
|
||||
return fileName.substring(folderId.length + 1);
|
||||
}
|
||||
return fileName;
|
||||
};
|
||||
|
|
@ -15,6 +15,7 @@ type CreateEtapeInput = {
|
|||
title: string;
|
||||
description?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
};
|
||||
|
||||
type UpdateEtapeInput = {
|
||||
|
|
@ -23,6 +24,7 @@ type UpdateEtapeInput = {
|
|||
title?: string;
|
||||
description?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
};
|
||||
|
||||
type DeleteEtapeInput = {
|
||||
|
|
@ -169,6 +171,7 @@ export const useCreateTask = () => {
|
|||
position: task.position || 0,
|
||||
parent_task_id: task.parent_task_id ?? null,
|
||||
is_parent: task.is_parent ?? false,
|
||||
due_date: task.due_date ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
|
@ -358,7 +361,7 @@ export const useCreateEtape = () => {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tabloId, title, description, position }: CreateEtapeInput) => {
|
||||
mutationFn: async ({ tabloId, title, description, position, due_date }: CreateEtapeInput) => {
|
||||
const { data, error } = await supabase
|
||||
.from("tasks")
|
||||
.insert({
|
||||
|
|
@ -368,6 +371,7 @@ export const useCreateEtape = () => {
|
|||
status: "todo",
|
||||
position: position ?? 0,
|
||||
is_parent: true,
|
||||
due_date: due_date ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
|
@ -404,11 +408,19 @@ export const useUpdateEtape = () => {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, tabloId, title, description, position }: UpdateEtapeInput) => {
|
||||
mutationFn: async ({
|
||||
id,
|
||||
tabloId,
|
||||
title,
|
||||
description,
|
||||
position,
|
||||
due_date,
|
||||
}: UpdateEtapeInput) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (position !== undefined) updates.position = position;
|
||||
if (due_date !== undefined) updates.due_date = due_date;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error("Aucune modification fournie pour l'Étape");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { FeedbackPage } from "../pages/feedback";
|
|||
import { JoinPage } from "../pages/join";
|
||||
import { LegalNoticePage } from "../pages/legal-notice";
|
||||
import { LoginPage } from "../pages/login";
|
||||
import { LoginV2Page } from "../pages/login-v2";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
// import NotesPage from "../pages/notes"; // Notes feature temporarily hidden
|
||||
import { OAuthSigninPage } from "../pages/oauth-signin";
|
||||
|
|
@ -19,9 +20,12 @@ import { PrivacyPolicyPage } from "../pages/privacy-policy";
|
|||
import { ResetPasswordPage } from "../pages/reset-password";
|
||||
import SettingsPage from "../pages/settings";
|
||||
import { SignUpPage } from "../pages/signup";
|
||||
import { SignUpV2Page } from "../pages/signup-v2";
|
||||
import { TabloPage } from "../pages/tablo";
|
||||
import { TabloDetailsPage } from "../pages/tablo-details";
|
||||
import { TablosPage } from "../pages/tablos";
|
||||
import { TasksPage } from "../pages/tasks";
|
||||
import { FilesPage } from "../pages/files";
|
||||
import { UpdatePasswordPage } from "../pages/update-password";
|
||||
import ChatProvider from "../providers/ChatProvider";
|
||||
import { EventsPage } from "src/pages/events";
|
||||
|
|
@ -32,14 +36,14 @@ export const routes: RouteObject[] = [
|
|||
path: "/",
|
||||
element: <ProtectedRoute fallback="/login" />,
|
||||
children: [
|
||||
{
|
||||
path: "tablos/:tabloId",
|
||||
element: <TabloDetailsPage />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{
|
||||
path: "tablos/:tabloId",
|
||||
element: <TabloDetailsPage />,
|
||||
},
|
||||
{
|
||||
index: true,
|
||||
element: <TabloPage />,
|
||||
|
|
@ -94,12 +98,23 @@ export const routes: RouteObject[] = [
|
|||
{
|
||||
path: "events",
|
||||
element: <EventsPage />,
|
||||
children: [{ index: true }, { path: "create", element: <EventModal mode="create" /> }],
|
||||
children: [
|
||||
{ index: true },
|
||||
{ path: "create", element: <EventModal mode="create" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
element: <TasksPage />,
|
||||
},
|
||||
{
|
||||
path: "files",
|
||||
element: <FilesPage />,
|
||||
},
|
||||
{
|
||||
path: "tablos",
|
||||
element: <TablosPage />,
|
||||
},
|
||||
{
|
||||
path: "feedback",
|
||||
element: <FeedbackPage />,
|
||||
|
|
@ -153,10 +168,18 @@ export const routes: RouteObject[] = [
|
|||
path: "login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "login-v2",
|
||||
element: <LoginV2Page />,
|
||||
},
|
||||
{
|
||||
path: "signup",
|
||||
element: <SignUpPage />,
|
||||
},
|
||||
{
|
||||
path: "signup-v2",
|
||||
element: <SignUpV2Page />,
|
||||
},
|
||||
{
|
||||
path: "reset-password",
|
||||
element: <ResetPasswordPage />,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@
|
|||
"forgotPassword": "Forgot password?",
|
||||
"loginButton": "Log in",
|
||||
"noAccount": "Don't have an account?",
|
||||
"signupLink": "Sign up"
|
||||
"signupLink": "Sign up",
|
||||
"newExperienceLink": "Take a look at the new login experience",
|
||||
"asideTitle": "Centralize your projects with Xtablo",
|
||||
"asideDescription": "Plan tasks, events and collaboration from one clean workspace.",
|
||||
"feature1": "Track every deadline with shared timelines",
|
||||
"feature2": "Keep files, discussions and decisions in context",
|
||||
"feature3": "Give your team one reliable source of truth"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an Xtablo account",
|
||||
|
|
@ -29,6 +35,11 @@
|
|||
"signupButton": "Create my account",
|
||||
"alreadyAccount": "Already have an account?",
|
||||
"loginLink": "Log in",
|
||||
"asideTitle": "Build a calmer workflow from day one",
|
||||
"asideDescription": "Create your workspace, invite your team, and keep every project in sync.",
|
||||
"feature1": "Shared task boards with clear ownership",
|
||||
"feature2": "Planning views for milestones and deadlines",
|
||||
"feature3": "Fast collaboration around files and updates",
|
||||
"termsAccept": "I accept the",
|
||||
"termsLink": "legal notice",
|
||||
"termsAnd": "and the",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"labels": {
|
||||
"title": "Title *",
|
||||
"tablo": "Tablo *",
|
||||
"tablo": "Project *",
|
||||
"date": "Date *",
|
||||
"startTime": "Start *",
|
||||
"endTime": "End",
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
},
|
||||
"placeholders": {
|
||||
"title": "Add a title",
|
||||
"tablo": "Select a tablo",
|
||||
"tablo": "Select a project",
|
||||
"description": "Add a description (optional)"
|
||||
},
|
||||
"buttons": {
|
||||
|
|
@ -80,10 +80,10 @@
|
|||
"required": "*"
|
||||
},
|
||||
"deleteTabloModal": {
|
||||
"title": "Delete tablo",
|
||||
"title": "Delete project",
|
||||
"subtitle": "This action is irreversible",
|
||||
"confirmQuestion": "Are you sure you want to delete the tablo",
|
||||
"warning": "All data associated with this tablo will be permanently lost.",
|
||||
"confirmQuestion": "Are you sure you want to delete the project",
|
||||
"warning": "All data associated with this project will be permanently lost.",
|
||||
"buttons": {
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
|
|
@ -105,11 +105,11 @@
|
|||
"importing": "Importing..."
|
||||
},
|
||||
"checkbox": {
|
||||
"createNewTablo": "Create a new tablo"
|
||||
"createNewTablo": "Create a new project"
|
||||
},
|
||||
"placeholders": {
|
||||
"newTabloName": "New tablo name",
|
||||
"selectTablo": "Select an existing tablo"
|
||||
"newTabloName": "New project name",
|
||||
"selectTablo": "Select an existing project"
|
||||
},
|
||||
"messages": {
|
||||
"eventsFound": "{{count}} event(s) found",
|
||||
|
|
@ -133,12 +133,12 @@
|
|||
"description": "No events to import"
|
||||
},
|
||||
"tabloRequired": {
|
||||
"title": "Tablo required",
|
||||
"description": "Please select a tablo or create a new one"
|
||||
"title": "Project required",
|
||||
"description": "Please select a project or create a new one"
|
||||
},
|
||||
"tabloNameRequired": {
|
||||
"title": "Tablo name required",
|
||||
"description": "Please enter a name for the new tablo"
|
||||
"title": "Project name required",
|
||||
"description": "Please enter a name for the new project"
|
||||
},
|
||||
"importSuccess": {
|
||||
"title": "Import successful",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"createTablo": {
|
||||
"title": "Create a new tablo",
|
||||
"nameLabel": "Tablo name",
|
||||
"namePlaceholder": "Enter tablo name"
|
||||
"title": "Create a new project",
|
||||
"nameLabel": "Project name",
|
||||
"namePlaceholder": "Enter project name"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
{
|
||||
"projects": "Tablos",
|
||||
"home": "Home",
|
||||
"tablos": "Projects",
|
||||
"projects": "Projects",
|
||||
"myEvents": "My Events",
|
||||
"planning": "Planning",
|
||||
"tasks": "Tasks",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"files": "Files",
|
||||
"feedback": "Feedback",
|
||||
"settings": "Settings",
|
||||
"availabilities": "Availabilities",
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@
|
|||
"shareNoteDescription": "Control who can access this note",
|
||||
"publicAccess": "Public Access",
|
||||
"publicAccessDescription": "Anyone with the link can view this note",
|
||||
"shareWithAllTablos": "Share with All Tablos",
|
||||
"shareWithAllTablosDescription": "Members of all your tablos can view this note",
|
||||
"shareWithAllTablos": "Share with All Projects",
|
||||
"shareWithAllTablosDescription": "Members of all your projects can view this note",
|
||||
"close": "Close",
|
||||
"saveNoteBeforeSharing": "Please save the note before sharing",
|
||||
"sharingSettingsUpdated": "Sharing settings updated",
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
"skipTutorial": "Skip onboarding",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"getStarted": "Create Tablo",
|
||||
"getStarted": "Create Project",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Welcome to XTablo",
|
||||
"description": "Here, you will create your first Tablo - a clear, elegant project tracking space shareable with your clients or team.",
|
||||
"description": "Here, you will create your first project - a clear, elegant project tracking space shareable with your clients or team.",
|
||||
"content": {
|
||||
"intro": "In less than 2 minutes, you will have a ready-to-use Tablo, structured around your services.",
|
||||
"cta": "Create my first Tablo"
|
||||
"intro": "In less than 2 minutes, you will have a ready-to-use project, structured around your services.",
|
||||
"cta": "Create my first project"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"service": {
|
||||
"title": "What is your main service?",
|
||||
"description": "Define the service you want to track in this Tablo",
|
||||
"description": "Define the service you want to track in this project",
|
||||
"question": "What is your service?",
|
||||
"placeholder": "Ex: Web Development, Consulting..."
|
||||
},
|
||||
|
|
@ -36,9 +36,9 @@
|
|||
"step_placeholder": "Step {{index}}"
|
||||
},
|
||||
"project": {
|
||||
"title": "Project Name (Tablo)",
|
||||
"description": "Give your first Tablo a name",
|
||||
"question": "What would you like to call this tablo?",
|
||||
"title": "Project Name",
|
||||
"description": "Give your first project a name",
|
||||
"question": "What would you like to call this project?",
|
||||
"placeholder": "ex: Landing Page - Client X"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
{
|
||||
"tablo": {
|
||||
"title": "Tablos",
|
||||
"subtitle": "Manage your tablos and collaborations",
|
||||
"createButton": "New tablo",
|
||||
"greeting": {
|
||||
"morning": "Good Morning",
|
||||
"afternoon": "Good Afternoon",
|
||||
"evening": "Good Evening"
|
||||
},
|
||||
"title": "Projects",
|
||||
"subtitle": "Manage your projects and collaborations",
|
||||
"createButton": "New project",
|
||||
"emptyState": {
|
||||
"title": "No tablos found",
|
||||
"description": "Create your first tablo to start organizing your work",
|
||||
"button": "Create your first tablo"
|
||||
"title": "No projects found",
|
||||
"description": "Create your first project to start organizing your work",
|
||||
"button": "Create your first project"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All",
|
||||
|
|
@ -30,7 +35,16 @@
|
|||
"contextMenu": {
|
||||
"openDiscussions": "Open discussions",
|
||||
"openPlanning": "Open planning",
|
||||
"delete": "Delete tablo"
|
||||
"delete": "Delete project"
|
||||
},
|
||||
"card": {
|
||||
"progress": "Progress"
|
||||
},
|
||||
"projectList": {
|
||||
"title": "My Projects",
|
||||
"seeAll": "See All",
|
||||
"showAll": "See {{count}} more",
|
||||
"showLess": "Show less"
|
||||
},
|
||||
"kpis": {
|
||||
"total": "Total",
|
||||
|
|
@ -53,7 +67,7 @@
|
|||
"createEventType": "New type",
|
||||
"search": "Search for an event...",
|
||||
"filters": {
|
||||
"allTablos": "All boards",
|
||||
"allTablos": "All projects",
|
||||
"upcoming": "Upcoming",
|
||||
"past": "Past"
|
||||
},
|
||||
|
|
@ -87,10 +101,10 @@
|
|||
},
|
||||
"tasks": {
|
||||
"title": "My Tasks",
|
||||
"subtitle": "Manage all your tasks across all your tablos",
|
||||
"subtitle": "Manage all your tasks across all your projects",
|
||||
"search": "Search for a task...",
|
||||
"filters": {
|
||||
"allTablos": "All boards",
|
||||
"allTablos": "All projects",
|
||||
"allAssignees": "All assignees",
|
||||
"assignedToMe": "Assigned to me",
|
||||
"unassigned": "Unassigned"
|
||||
|
|
@ -98,7 +112,7 @@
|
|||
"emptyState": {
|
||||
"title": "No tasks found",
|
||||
"noResults": "Try changing your search filters.",
|
||||
"noTasks": "Start by creating your first task in a tablo."
|
||||
"noTasks": "Start by creating your first task in a project."
|
||||
},
|
||||
"unassigned": "Unassigned",
|
||||
"pagination": {
|
||||
|
|
@ -115,7 +129,7 @@
|
|||
},
|
||||
"view": {
|
||||
"kanban": "Kanban View",
|
||||
"aggregated": "By Tablo View"
|
||||
"aggregated": "By Project View"
|
||||
},
|
||||
"createTask": "New Task"
|
||||
},
|
||||
|
|
@ -150,5 +164,35 @@
|
|||
"title": "Thank you for your feedback!",
|
||||
"description": "Your feedback has been sent successfully. We appreciate you taking the time to help us improve."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actionCards": {
|
||||
"createProject": {
|
||||
"label": "Create Project",
|
||||
"description": "Set goals and scope"
|
||||
},
|
||||
"createTask": {
|
||||
"label": "Create Task",
|
||||
"description": "Break work into actions"
|
||||
},
|
||||
"inviteTeam": {
|
||||
"label": "Invite Team",
|
||||
"description": "Add collaborators instantly"
|
||||
},
|
||||
"sendMessage": {
|
||||
"label": "Send Message",
|
||||
"description": "Communicate updates fast"
|
||||
}
|
||||
},
|
||||
"taskList": {
|
||||
"title": "My Tasks",
|
||||
"addTask": "Add Task",
|
||||
"status": {
|
||||
"todo": "To Do",
|
||||
"inProgress": "In Progress",
|
||||
"inReview": "In Review",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"title": "Planning",
|
||||
"allEvents": "All events",
|
||||
"allTablos": "All tablos",
|
||||
"selectTablo": "Select a tablo",
|
||||
"allTablos": "All projects",
|
||||
"selectTablo": "Select a project",
|
||||
"createEvent": "Create event",
|
||||
"importPlanning": "Import planning",
|
||||
"today": "Today",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"description": "Configure the Stages of the tablo to clarify the major phases of your tablo.",
|
||||
"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"
|
||||
},
|
||||
|
|
@ -9,10 +9,10 @@
|
|||
"nameRequired": "The Stage name is required",
|
||||
"namePlaceholder": "Stage Name",
|
||||
"deleteConfirm": "Are you sure you want to delete the Stage \"{{name}}\"? Associated tasks will remain available.",
|
||||
"noEtapes": "No Stages have been defined for this tablo yet.",
|
||||
"createFirstEtape": "Create your first Stage to structure the tablo tasks.",
|
||||
"onlyOwnerCanAdd": "Only the tablo owner can add Stages.",
|
||||
"onlyOwnerCanModify": "Only the tablo owner can modify Stages. Contact the administrator if you need a new Stage.",
|
||||
"noEtapes": "No Stages have been defined for this project yet.",
|
||||
"createFirstEtape": "Create your first Stage to structure the project tasks.",
|
||||
"onlyOwnerCanAdd": "Only the project owner can add Stages.",
|
||||
"onlyOwnerCanModify": "Only the project owner can modify Stages. Contact the administrator if you need a new Stage.",
|
||||
"stepNumber": "Stage {{number}}",
|
||||
"addNew": "New Stage"
|
||||
},
|
||||
|
|
@ -25,6 +25,6 @@
|
|||
},
|
||||
"events": {
|
||||
"title": "Upcoming events",
|
||||
"description": "Manage the future events of this tablo"
|
||||
"description": "Manage the future events of this project"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@
|
|||
"forgotPassword": "Mot de passe oublié ?",
|
||||
"loginButton": "Se connecter",
|
||||
"noAccount": "Pas encore de compte ?",
|
||||
"signupLink": "S'inscrire"
|
||||
"signupLink": "S'inscrire",
|
||||
"newExperienceLink": "Découvrez la nouvelle expérience de connexion",
|
||||
"asideTitle": "Centralisez vos projets avec Xtablo",
|
||||
"asideDescription": "Planifiez tâches, événements et collaboration depuis un espace unique.",
|
||||
"feature1": "Suivez chaque échéance avec des timelines partagées",
|
||||
"feature2": "Gardez fichiers, discussions et décisions au même endroit",
|
||||
"feature3": "Donnez à votre équipe une source unique de vérité"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Créer un compte Xtablo",
|
||||
|
|
@ -29,6 +35,11 @@
|
|||
"signupButton": "Créer mon compte",
|
||||
"alreadyAccount": "Déjà un compte ?",
|
||||
"loginLink": "Se connecter",
|
||||
"asideTitle": "Créez un workflow plus serein dès le premier jour",
|
||||
"asideDescription": "Créez votre espace, invitez votre équipe et synchronisez vos projets.",
|
||||
"feature1": "Des tableaux de tâches partagés avec des responsabilités claires",
|
||||
"feature2": "Des vues planning pour les jalons et échéances",
|
||||
"feature3": "Une collaboration rapide autour des fichiers et mises à jour",
|
||||
"termsAccept": "J'accepte les",
|
||||
"termsLink": "mentions légales",
|
||||
"termsAnd": "et la",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"labels": {
|
||||
"title": "Titre *",
|
||||
"tablo": "Tablo *",
|
||||
"tablo": "Projet *",
|
||||
"date": "Date *",
|
||||
"startTime": "Début *",
|
||||
"endTime": "Fin",
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
},
|
||||
"placeholders": {
|
||||
"title": "Ajouter un titre",
|
||||
"tablo": "Sélectionner un tablo",
|
||||
"tablo": "Sélectionner un projet",
|
||||
"description": "Ajouter une description (optionnel)"
|
||||
},
|
||||
"buttons": {
|
||||
|
|
@ -80,10 +80,10 @@
|
|||
"required": "*"
|
||||
},
|
||||
"deleteTabloModal": {
|
||||
"title": "Supprimer le tablo",
|
||||
"title": "Supprimer le projet",
|
||||
"subtitle": "Cette action est irréversible",
|
||||
"confirmQuestion": "Êtes-vous sûr de vouloir supprimer le tablo",
|
||||
"warning": "Toutes les données associées à ce tablo seront perdues définitivement.",
|
||||
"confirmQuestion": "Êtes-vous sûr de vouloir supprimer le projet",
|
||||
"warning": "Toutes les données associées à ce projet seront perdues définitivement.",
|
||||
"buttons": {
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
|
|
@ -105,11 +105,11 @@
|
|||
"importing": "Import en cours..."
|
||||
},
|
||||
"checkbox": {
|
||||
"createNewTablo": "Créer un nouveau tablo"
|
||||
"createNewTablo": "Créer un nouveau projet"
|
||||
},
|
||||
"placeholders": {
|
||||
"newTabloName": "Nom du nouveau tablo",
|
||||
"selectTablo": "Sélectionner un tablo existant"
|
||||
"newTabloName": "Nom du nouveau projet",
|
||||
"selectTablo": "Sélectionner un projet existant"
|
||||
},
|
||||
"messages": {
|
||||
"eventsFound": "{{count}} événement(s) trouvé(s)",
|
||||
|
|
@ -133,12 +133,12 @@
|
|||
"description": "Aucun événement à importer"
|
||||
},
|
||||
"tabloRequired": {
|
||||
"title": "Tablo requis",
|
||||
"description": "Veuillez sélectionner un tablo ou créer un nouveau"
|
||||
"title": "Projet requis",
|
||||
"description": "Veuillez sélectionner un projet ou en créer un nouveau"
|
||||
},
|
||||
"tabloNameRequired": {
|
||||
"title": "Nom du tablo requis",
|
||||
"description": "Veuillez saisir un nom pour le nouveau tablo"
|
||||
"title": "Nom du projet requis",
|
||||
"description": "Veuillez saisir un nom pour le nouveau projet"
|
||||
},
|
||||
"importSuccess": {
|
||||
"title": "Import réussi",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"createTablo": {
|
||||
"title": "Créer un nouveau tablo",
|
||||
"nameLabel": "Nom du tablo",
|
||||
"namePlaceholder": "Entrez le nom du tablo"
|
||||
"title": "Créer un nouveau projet",
|
||||
"nameLabel": "Nom du projet",
|
||||
"namePlaceholder": "Entrez le nom du projet"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
{
|
||||
"projects": "Tablos",
|
||||
"home": "Aperçu",
|
||||
"tablos": "Projets",
|
||||
"projects": "Projets",
|
||||
"myEvents": "Mes Événements",
|
||||
"planning": "Planning",
|
||||
"tasks": "Tâches",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"files": "Fichiers",
|
||||
"feedback": "Feedback",
|
||||
"settings": "Paramètres",
|
||||
"availabilities": "Disponibilités",
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@
|
|||
"shareNoteDescription": "Contrôlez qui peut accéder à cette note",
|
||||
"publicAccess": "Accès public",
|
||||
"publicAccessDescription": "Toute personne avec le lien peut voir cette note",
|
||||
"shareWithAllTablos": "Partager avec tous les tablos",
|
||||
"shareWithAllTablosDescription": "Les membres de tous vos tablos peuvent voir cette note",
|
||||
"shareWithAllTablos": "Partager avec tous les projets",
|
||||
"shareWithAllTablosDescription": "Les membres de tous vos projets peuvent voir cette note",
|
||||
"close": "Fermer",
|
||||
"saveNoteBeforeSharing": "Veuillez enregistrer la note avant de la partager",
|
||||
"sharingSettingsUpdated": "Paramètres de partage mis à jour",
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
"skipTutorial": "Passer l'onboarding",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"getStarted": "Créer le Tablo",
|
||||
"getStarted": "Créer le projet",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Bienvenue sur XTablo",
|
||||
"description": "Ici, vous allez créer votre premier Tablo - un espace de suivi projet, clair, élégant et partageable avec vos clients.",
|
||||
"description": "Ici, vous allez créer votre premier projet - un espace de suivi clair, élégant et partageable avec vos clients.",
|
||||
"content": {
|
||||
"intro": "En moins de 2 minutes, vous aurez un Tablo prêt à l'emploi, structuré autour de vos services.",
|
||||
"cta": "Créer mon premier Tablo"
|
||||
"intro": "En moins de 2 minutes, vous aurez un projet prêt à l'emploi, structuré autour de vos services.",
|
||||
"cta": "Créer mon premier projet"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"service": {
|
||||
"title": "Quel est votre service principal ?",
|
||||
"description": "Définissez le service que vous souhaitez suivre dans ce Tablo",
|
||||
"description": "Définissez le service que vous souhaitez suivre dans ce projet",
|
||||
"question": "Quel est votre service ?",
|
||||
"placeholder": "Ex: Création de site web, Consulting..."
|
||||
},
|
||||
|
|
@ -36,9 +36,9 @@
|
|||
"step_placeholder": "Étape {{index}}"
|
||||
},
|
||||
"project": {
|
||||
"title": "Nom du projet (tablo)",
|
||||
"description": "Donnez un nom à votre premier Tablo",
|
||||
"question": "Comment souhaitez-vous appeler ce tablo ?",
|
||||
"title": "Nom du projet",
|
||||
"description": "Donnez un nom à votre premier projet",
|
||||
"question": "Comment souhaitez-vous appeler ce projet ?",
|
||||
"placeholder": "ex : Landing Page - Client X"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
{
|
||||
"tablo": {
|
||||
"title": "Tablos",
|
||||
"subtitle": "Gérez vos tablos et collaborations",
|
||||
"createButton": "Nouveau tablo",
|
||||
"greeting": {
|
||||
"morning": "Bonjour",
|
||||
"afternoon": "Bonjour",
|
||||
"evening": "Bonsoir"
|
||||
},
|
||||
"title": "Projets",
|
||||
"subtitle": "Gérez vos projets et collaborations",
|
||||
"createButton": "Nouveau projet",
|
||||
"emptyState": {
|
||||
"title": "Aucun tablo trouvé",
|
||||
"description": "Créez votre premier tablo pour commencer à organiser votre travail",
|
||||
"button": "Créer votre premier tablo"
|
||||
"title": "Aucun projet trouvé",
|
||||
"description": "Créez votre premier projet pour commencer à organiser votre travail",
|
||||
"button": "Créer votre premier projet"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Tous",
|
||||
|
|
@ -30,7 +35,16 @@
|
|||
"contextMenu": {
|
||||
"openDiscussions": "Ouvrir les discussions",
|
||||
"openPlanning": "Ouvrir le planning",
|
||||
"delete": "Supprimer le tablo"
|
||||
"delete": "Supprimer le projet"
|
||||
},
|
||||
"card": {
|
||||
"progress": "Progression"
|
||||
},
|
||||
"projectList": {
|
||||
"title": "Mes Projets",
|
||||
"seeAll": "Voir tout",
|
||||
"showAll": "Voir {{count}} de plus",
|
||||
"showLess": "Réduire"
|
||||
},
|
||||
"kpis": {
|
||||
"total": "Total",
|
||||
|
|
@ -53,7 +67,7 @@
|
|||
"createEventType": "Nouveau type",
|
||||
"search": "Rechercher un événement...",
|
||||
"filters": {
|
||||
"allTablos": "Tous les tablos",
|
||||
"allTablos": "Tous les projets",
|
||||
"upcoming": "À venir",
|
||||
"past": "Passés"
|
||||
},
|
||||
|
|
@ -87,10 +101,10 @@
|
|||
},
|
||||
"tasks": {
|
||||
"title": "Mes Tâches",
|
||||
"subtitle": "Gérez toutes vos tâches à travers tous vos tablos",
|
||||
"subtitle": "Gérez toutes vos tâches à travers tous vos projets",
|
||||
"search": "Rechercher une tâche...",
|
||||
"filters": {
|
||||
"allTablos": "Tous les tablos",
|
||||
"allTablos": "Tous les projets",
|
||||
"allAssignees": "Tous les assignés",
|
||||
"assignedToMe": "Assignées à moi",
|
||||
"unassigned": "Non assignées"
|
||||
|
|
@ -98,7 +112,7 @@
|
|||
"emptyState": {
|
||||
"title": "Aucune tâche trouvée",
|
||||
"noResults": "Essayez de modifier vos filtres de recherche.",
|
||||
"noTasks": "Commencez par créer votre première tâche dans un tablo."
|
||||
"noTasks": "Commencez par créer votre première tâche dans un projet."
|
||||
},
|
||||
"unassigned": "Non assignée",
|
||||
"pagination": {
|
||||
|
|
@ -115,7 +129,7 @@
|
|||
},
|
||||
"view": {
|
||||
"kanban": "Vue Kanban",
|
||||
"aggregated": "Vue par tablo"
|
||||
"aggregated": "Vue par projet"
|
||||
},
|
||||
"createTask": "Nouvelle tâche"
|
||||
},
|
||||
|
|
@ -150,5 +164,35 @@
|
|||
"title": "Merci pour votre commentaire !",
|
||||
"description": "Votre commentaire a été envoyé avec succès. Nous apprécions que vous ayez pris le temps de nous aider à nous améliorer."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actionCards": {
|
||||
"createProject": {
|
||||
"label": "Créer un projet",
|
||||
"description": "Définir les objectifs et le périmètre"
|
||||
},
|
||||
"createTask": {
|
||||
"label": "Créer une tâche",
|
||||
"description": "Découper le travail en actions"
|
||||
},
|
||||
"inviteTeam": {
|
||||
"label": "Inviter l'équipe",
|
||||
"description": "Ajouter des collaborateurs"
|
||||
},
|
||||
"sendMessage": {
|
||||
"label": "Envoyer un message",
|
||||
"description": "Communiquer rapidement"
|
||||
}
|
||||
},
|
||||
"taskList": {
|
||||
"title": "Mes Tâches",
|
||||
"addTask": "Ajouter",
|
||||
"status": {
|
||||
"todo": "À faire",
|
||||
"inProgress": "En cours",
|
||||
"inReview": "En revue",
|
||||
"done": "Terminé"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"title": "Planning",
|
||||
"allEvents": "Tous les événements",
|
||||
"allTablos": "Tous les tablos",
|
||||
"selectTablo": "Sélectionner un tablo",
|
||||
"allTablos": "Tous les projets",
|
||||
"selectTablo": "Sélectionner un projet",
|
||||
"createEvent": "Créer un événement",
|
||||
"importPlanning": "Importer un planning",
|
||||
"today": "Aujourd'hui",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"overview": {
|
||||
"title": "Vue d'ensemble",
|
||||
"description": "Configurez les Étapes du tablo pour clarifier les grandes phases de votre tablo.",
|
||||
"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)"
|
||||
},
|
||||
|
|
@ -9,10 +9,10 @@
|
|||
"nameRequired": "Le nom de l'Étape est requis",
|
||||
"namePlaceholder": "Nom de l'Étape",
|
||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer l'Étape \"{{name}}\" ? Les tâches associées resteront disponibles.",
|
||||
"noEtapes": "Aucune Étape n'a encore été définie pour ce tablo.",
|
||||
"createFirstEtape": "Créez votre première Étape pour structurer les tâches du tablo.",
|
||||
"onlyOwnerCanAdd": "Seul le propriétaire du tablo peut ajouter des Étapes.",
|
||||
"onlyOwnerCanModify": "Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape.",
|
||||
"noEtapes": "Aucune Étape n'a encore été définie pour ce projet.",
|
||||
"createFirstEtape": "Créez votre première Étape pour structurer les tâches du projet.",
|
||||
"onlyOwnerCanAdd": "Seul le propriétaire du projet peut ajouter des Étapes.",
|
||||
"onlyOwnerCanModify": "Seul le propriétaire du projet peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape.",
|
||||
"stepNumber": "Étape {{number}}",
|
||||
"addNew": "Ajouter l'Étape"
|
||||
},
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
},
|
||||
"events": {
|
||||
"title": "Événements à venir",
|
||||
"description": "Gérez les événements futurs de ce tablo",
|
||||
"description": "Gérez les événements futurs de ce projet",
|
||||
"createEvent": "Créer un événement"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -36,7 +36,11 @@ export function ChatPage() {
|
|||
}, [channelFromUrl]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-background">
|
||||
<div className="flex flex-col h-[calc(100vh-75px)] bg-gray-50 dark:bg-background">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 shrink-0">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Discussions</h1>
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isChannelListExpanded ? "w-80" : "w-0"
|
||||
|
|
@ -78,6 +82,7 @@ export function ChatPage() {
|
|||
</Window>
|
||||
</Channel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
501
apps/main/src/pages/files.tsx
Normal file
501
apps/main/src/pages/files.tsx
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@xtablo/ui/components/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@xtablo/ui/components/dropdown-menu";
|
||||
import {
|
||||
DownloadIcon,
|
||||
EllipsisVerticalIcon,
|
||||
FileTextIcon,
|
||||
FolderIcon,
|
||||
LayersIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
extractFolderIdFromFileName,
|
||||
getFileNameWithoutFolder,
|
||||
getFolderFilePrefix,
|
||||
useTabloFolders,
|
||||
} from "../hooks/tablo_folders";
|
||||
import {
|
||||
useAllTablosFileNames,
|
||||
useCreateTabloFile,
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
} from "../hooks/tablo_data";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
|
||||
// Derive icon color from file extension
|
||||
function getFileIconColor(fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) return "bg-purple-500";
|
||||
if (ext === "pdf") return "bg-red-500";
|
||||
if (["xlsx", "xls", "csv"].includes(ext)) return "bg-green-600";
|
||||
if (["doc", "docx"].includes(ext)) return "bg-blue-500";
|
||||
return "bg-gray-500";
|
||||
}
|
||||
|
||||
// ─── Upload Modal ────────────────────────────────────────────────────────────
|
||||
|
||||
function UploadModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tablos,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tablos: { id: string; name: string; color: string | null }[];
|
||||
}) {
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablos[0]?.id ?? "");
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string>("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: foldersData } = useTabloFolders(selectedTabloId);
|
||||
const folders = foldersData?.folders ?? [];
|
||||
const createFile = useCreateTabloFile();
|
||||
|
||||
const handleTabloChange = (tabloId: string) => {
|
||||
setSelectedTabloId(tabloId);
|
||||
setSelectedFolderId("");
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !selectedTabloId) return;
|
||||
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
toast.add(
|
||||
{ title: "Erreur", description: "Le fichier ne peut pas dépasser 20MB", type: "error" },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => resolve(ev.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
if (file.type.startsWith("text/") || file.type === "application/json") {
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
const fileName = selectedFolderId
|
||||
? `${getFolderFilePrefix(selectedFolderId)}${file.name}`
|
||||
: file.name;
|
||||
|
||||
await createFile.mutateAsync({
|
||||
tabloId: selectedTabloId,
|
||||
fileName,
|
||||
data: { content, contentType: file.type || "application/octet-stream" },
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch {
|
||||
// error handled by hook
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload a file</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Project selector */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<div className="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
|
||||
{tablos.map((tablo) => (
|
||||
<button
|
||||
key={tablo.id}
|
||||
type="button"
|
||||
onClick={() => handleTabloChange(tablo.id)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
|
||||
selectedTabloId === tablo.id
|
||||
? "border-primary bg-primary/5 dark:bg-primary/10"
|
||||
: "border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-6 h-6 rounded-md shrink-0 flex items-center justify-center text-xs font-bold text-white ${tablo.color ?? "bg-gray-500"}`}
|
||||
>
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder selector (optional) */}
|
||||
{folders.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Folder (optional)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFolderId("")}
|
||||
className={`px-3 py-1.5 rounded-lg border text-sm transition-colors ${
|
||||
selectedFolderId === ""
|
||||
? "border-primary bg-primary/5 dark:bg-primary/10 text-primary"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
No folder
|
||||
</button>
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedFolderId(folder.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm transition-colors ${
|
||||
selectedFolderId === folder.id
|
||||
? "border-primary bg-primary/5 dark:bg-primary/10 text-primary"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<FolderIcon className="w-3.5 h-3.5" />
|
||||
{folder.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File picker */}
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileSelect} />
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedTabloId || isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Choose file & upload
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Folder cards grid ───────────────────────────────────────────────────────
|
||||
|
||||
function FolderGrid({
|
||||
folders,
|
||||
folderMap,
|
||||
}: {
|
||||
folders: { id: string; name: string; description?: string }[];
|
||||
folderMap: Map<string, string[]>;
|
||||
}) {
|
||||
const visibleFolders = folders.filter((f) => (folderMap.get(f.id) ?? []).length > 0);
|
||||
if (visibleFolders.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mb-5">
|
||||
{visibleFolders.map((folder) => {
|
||||
const count = folderMap.get(folder.id)?.length ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl p-4 border border-[#F2F4F7] dark:border-gray-700 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center">
|
||||
<FolderIcon className="w-5 h-5 text-amber-500 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight truncate">
|
||||
{folder.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{count} file{count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Per-project section ─────────────────────────────────────────────────────
|
||||
|
||||
function TabloFilesSection({
|
||||
tabloId,
|
||||
tabloName,
|
||||
tabloColor,
|
||||
fileNames,
|
||||
}: {
|
||||
tabloId: string;
|
||||
tabloName: string;
|
||||
tabloColor: string | null;
|
||||
fileNames: string[];
|
||||
}) {
|
||||
const { data: foldersData } = useTabloFolders(tabloId);
|
||||
const { mutate: downloadFile } = useDownloadTabloFile();
|
||||
const { mutate: deleteFile } = useDeleteTabloFile();
|
||||
|
||||
const allFileNames = fileNames.filter((f) => !f.startsWith("."));
|
||||
const folders = foldersData?.folders ?? [];
|
||||
|
||||
const folderMap = new Map<string, string[]>();
|
||||
const rootFiles: string[] = [];
|
||||
|
||||
for (const fileName of allFileNames) {
|
||||
const folderId = extractFolderIdFromFileName(fileName);
|
||||
if (folderId) {
|
||||
if (!folderMap.has(folderId)) folderMap.set(folderId, []);
|
||||
folderMap.get(folderId)!.push(fileName);
|
||||
} else {
|
||||
rootFiles.push(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (allFileNames.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
{/* Project header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-lg shrink-0 flex items-center justify-center text-sm font-bold text-white ${tabloColor ?? "bg-gray-500"}`}
|
||||
>
|
||||
{tabloName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<Link
|
||||
to={`/tablos/${tabloId}`}
|
||||
className="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{tabloName}
|
||||
</Link>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{allFileNames.length} file{allFileNames.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Folder cards */}
|
||||
<FolderGrid folders={folders} folderMap={folderMap} />
|
||||
|
||||
{/* Files per folder */}
|
||||
{folders.map((folder) => {
|
||||
const folderFiles = folderMap.get(folder.id) ?? [];
|
||||
if (folderFiles.length === 0) return null;
|
||||
return (
|
||||
<div key={folder.id} className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<FolderIcon className="w-4 h-4 text-amber-500 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{folder.name}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">({folderFiles.length})</span>
|
||||
</div>
|
||||
<FileTable
|
||||
fileNames={folderFiles}
|
||||
onDownload={(fileName) => downloadFile({ tabloId, fileName })}
|
||||
onDelete={(fileName) => deleteFile({ tabloId, fileName })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Root files */}
|
||||
{rootFiles.length > 0 && (
|
||||
<>
|
||||
{folders.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Other files</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">({rootFiles.length})</span>
|
||||
</div>
|
||||
)}
|
||||
<FileTable
|
||||
fileNames={rootFiles}
|
||||
onDownload={(fileName) => downloadFile({ tabloId, fileName })}
|
||||
onDelete={(fileName) => deleteFile({ tabloId, fileName })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── File table ───────────────────────────────────────────────────────────────
|
||||
|
||||
function FileTable({
|
||||
fileNames,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
fileNames: string[];
|
||||
onDownload: (fileName: string) => void;
|
||||
onDelete: (fileName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-100 dark:border-gray-700 overflow-hidden mb-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-y border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-normal text-gray-900 dark:text-gray-300">File name</th>
|
||||
<th className="px-6 py-3 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{fileNames.map((fileName) => {
|
||||
const displayName = getFileNameWithoutFolder(fileName);
|
||||
const iconColor = getFileIconColor(displayName);
|
||||
return (
|
||||
<tr key={fileName} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 ${iconColor} rounded-lg flex items-center justify-center flex-shrink-0`}
|
||||
>
|
||||
<FileTextIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<p className="text-sm font-normal text-gray-900 dark:text-gray-100">{displayName}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
aria-label={`Actions for ${displayName}`}
|
||||
>
|
||||
<EllipsisVerticalIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDownload(fileName)}>
|
||||
<DownloadIcon className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(fileName)}
|
||||
className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400"
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FilesPage() {
|
||||
const { t } = useTranslation("navigation");
|
||||
const [searchParams] = useSearchParams();
|
||||
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
|
||||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
const { data: allFiles, isLoading: filesLoading } = useAllTablosFileNames();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
|
||||
const isLoading = tablosLoading || filesLoading;
|
||||
|
||||
const filesByTabloId = new Map<string, string[]>(
|
||||
(allFiles?.tablos ?? []).map(({ tabloId, fileNames }) => [tabloId, fileNames])
|
||||
);
|
||||
|
||||
const tablosWithFiles = (tablos ?? []).filter((tablo) => {
|
||||
const files = filesByTabloId.get(tablo.id) ?? [];
|
||||
const visibleFiles = files.filter((f) => !f.startsWith("."));
|
||||
if (searchQuery) {
|
||||
return visibleFiles.some((f) =>
|
||||
f.toLowerCase().includes(searchQuery) || tablo.name.toLowerCase().includes(searchQuery)
|
||||
);
|
||||
}
|
||||
return visibleFiles.length > 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="py-6 px-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t("files", "Files")}</h1>
|
||||
<Button
|
||||
className="gap-2 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
disabled={!tablos || tablos.length === 0}
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : tablosWithFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<LayersIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">No files found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{tablosWithFiles.map((tablo) => (
|
||||
<TabloFilesSection
|
||||
key={tablo.id}
|
||||
tabloId={tablo.id}
|
||||
tabloName={tablo.name}
|
||||
tabloColor={tablo.color}
|
||||
fileNames={filesByTabloId.get(tablo.id) ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tablos && tablos.length > 0 && (
|
||||
<UploadModal
|
||||
isOpen={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
tablos={tablos}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
278
apps/main/src/pages/login-v2.tsx
Normal file
278
apps/main/src/pages/login-v2.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { AnimatedBackground } from "@ui/components/AnimatedBackground";
|
||||
import { LoginWithGoogle } from "@ui/components/BrandButtons/LoginWithGoogle";
|
||||
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { FieldError } from "@xtablo/ui/components/field";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useLoginEmail } from "../hooks/auth";
|
||||
|
||||
export function LoginV2Page() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const emailParam = searchParams.get("email");
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const redirectUrl = localStorage.getItem("redirectUrl");
|
||||
const {
|
||||
mutate: login,
|
||||
isPending,
|
||||
errors,
|
||||
} = useLoginEmail({
|
||||
redirectUrl: redirectUrl ?? null,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: emailParam ?? "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "light") {
|
||||
setTheme("dark");
|
||||
} else if (theme === "dark") {
|
||||
setTheme("system");
|
||||
} else {
|
||||
setTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
const getThemeIcon = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <SunIcon className="w-5 h-5" />;
|
||||
case "dark":
|
||||
return <MoonIcon className="w-5 h-5" />;
|
||||
case "system":
|
||||
return <MonitorIcon className="w-5 h-5" />;
|
||||
default:
|
||||
return <SunIcon className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-background text-foreground">
|
||||
<AnimatedBackground />
|
||||
|
||||
<div className="relative z-10 min-h-screen md:grid md:grid-cols-[minmax(0,560px)_1fr]">
|
||||
<main className="flex min-h-screen items-center px-6 py-10 md:px-10 lg:px-14">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="mb-7 flex items-center justify-between">
|
||||
<a
|
||||
href="https://www.xtablo.com"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t("auth:common.backHome")}
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("auth:common.themeToggle", { theme })}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src="/logo_dark.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo_white.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("auth:login.title")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
{t("auth:login.noAccount")}{" "}
|
||||
<Link
|
||||
to="/signup-v2"
|
||||
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
|
||||
>
|
||||
{t("auth:login.signupLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("common:labels.email")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:login.emailPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.email && (
|
||||
<FieldError errors={[{ message: errors.email }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common:labels.password")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:login.passwordPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.password && (
|
||||
<FieldError errors={[{ message: errors.password }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
to="/reset-password"
|
||||
className="text-sm text-[#804EEC] hover:text-[#6f3fd4] transition-colors"
|
||||
>
|
||||
{t("auth:login.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
type="submit"
|
||||
>
|
||||
{isPending
|
||||
? t("auth:common.connecting")
|
||||
: t("auth:login.loginButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-3 bg-background text-xs text-muted-foreground">
|
||||
{t("auth:common.orContinue")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginWithGoogle />
|
||||
|
||||
<div className="mt-10 text-xs text-muted-foreground space-y-2">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Xtablo. {t("auth:common.backHome")}
|
||||
</p>
|
||||
<p className="flex gap-3">
|
||||
<Link
|
||||
to="/legal-notice"
|
||||
target="_blank"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("auth:signup.termsLink")}
|
||||
</Link>
|
||||
<span>|</span>
|
||||
<Link
|
||||
to="/privacy-policy"
|
||||
target="_blank"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("auth:signup.privacyLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="hidden md:flex min-h-screen items-center justify-center px-8 lg:px-14 border-l border-border bg-gradient-to-br from-[#F5F0FF] via-[#ECE4FF] to-[#DCCEFF] dark:from-[#201933] dark:via-[#271F3E] dark:to-[#2F2548]">
|
||||
<div className="relative w-full max-w-xl rounded-3xl border border-white/50 dark:border-white/10 bg-white/80 dark:bg-[#171224]/85 backdrop-blur-sm shadow-2xl p-10">
|
||||
<SparklesIcon className="absolute top-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
<SparklesIcon className="absolute top-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
<SparklesIcon className="absolute bottom-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
<SparklesIcon className="absolute bottom-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-20 h-20 rounded-full border border-border bg-card flex items-center justify-center shadow-sm">
|
||||
<img
|
||||
src="/logo_dark.png"
|
||||
alt="Xtablo"
|
||||
className="w-12 h-12 object-contain block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo_white.png"
|
||||
alt="Xtablo"
|
||||
className="w-12 h-12 object-contain hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-center mb-3">
|
||||
{t("auth:login.asideTitle")}
|
||||
</h2>
|
||||
<p className="text-center text-muted-foreground mb-8">
|
||||
{t("auth:login.asideDescription")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
|
||||
{t("auth:login.feature1")}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
|
||||
{t("auth:login.feature2")}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
|
||||
{t("auth:login.feature3")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/signup-v2"
|
||||
className="inline-flex w-full items-center justify-center rounded-xl bg-[#804EEC] px-5 py-3 text-sm font-semibold text-white hover:bg-[#6f3fd4] transition-colors"
|
||||
>
|
||||
{t("auth:login.signupLink")}
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ export function LoginPage() {
|
|||
const rotateY = ((x - centerX) / centerX) * 1;
|
||||
|
||||
setTransform(
|
||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`
|
||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`,
|
||||
);
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
|
@ -101,7 +101,7 @@ export function LoginPage() {
|
|||
ref={cardRef}
|
||||
className={twMerge(
|
||||
"w-full max-w-lg rounded-2xl relative",
|
||||
"transition-transform duration-200 ease-out will-change-transform"
|
||||
"transition-transform duration-200 ease-out will-change-transform",
|
||||
)}
|
||||
style={{ transform }}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
|
@ -116,7 +116,7 @@ export function LoginPage() {
|
|||
"relative w-full h-full p-8 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
|
||||
isHovered
|
||||
? "shadow-[0_15px_35px_rgba(0,0,0,0.15)] dark:shadow-[0_15px_35px_rgba(0,0,0,0.3)]"
|
||||
: "shadow-xl shadow-black/10 dark:shadow-black/25"
|
||||
: "shadow-xl shadow-black/10 dark:shadow-black/25",
|
||||
)}
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
|
|
@ -124,7 +124,12 @@ export function LoginPage() {
|
|||
href="https://www.xtablo.com"
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -165,38 +170,60 @@ export function LoginPage() {
|
|||
{t("auth:login.title")}
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 text-center">
|
||||
<Link
|
||||
to="/login-v2"
|
||||
className="inline-flex items-center text-sm text-[#804EEC] hover:text-[#6f3fd4] font-medium transition-colors"
|
||||
>
|
||||
{t("auth:login.newExperienceLink")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 flex flex-col items-center">
|
||||
<form className="space-y-4 w-95 max-w-md mx-auto" onSubmit={onSubmit}>
|
||||
<form
|
||||
className="space-y-4 w-95 max-w-md mx-auto"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("common:labels.email")} <span className="text-red-500">*</span>
|
||||
{t("common:labels.email")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:login.emailPlaceholder")}
|
||||
/>
|
||||
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
|
||||
{errors?.email && (
|
||||
<FieldError errors={[{ message: errors.email }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common:labels.password")} <span className="text-red-500">*</span>
|
||||
{t("common:labels.password")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:login.passwordPlaceholder")}
|
||||
/>
|
||||
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
|
||||
{errors?.password && (
|
||||
<FieldError errors={[{ message: errors.password }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
|
|
@ -209,7 +236,9 @@ export function LoginPage() {
|
|||
</div>
|
||||
|
||||
<Button className="w-full" type="submit">
|
||||
{isPending ? t("auth:common.connecting") : t("auth:login.loginButton")}
|
||||
{isPending
|
||||
? t("auth:common.connecting")
|
||||
: t("auth:login.loginButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
|
@ -226,7 +255,7 @@ export function LoginPage() {
|
|||
"rounded-full",
|
||||
"relative z-10",
|
||||
"before:absolute before:w-[100px] before:h-px before:bg-border before:left-[-110px] before:top-1/2",
|
||||
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2"
|
||||
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2",
|
||||
)}
|
||||
>
|
||||
{t("auth:common.orContinue")}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ import { WebcalModal } from "@ui/components/WebcalModal";
|
|||
import { downloadICSFile, generateICSFromEvents, toast } from "@xtablo/shared";
|
||||
import { EventAndTablo } from "@xtablo/shared/types/events.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@xtablo/ui/components/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -11,16 +18,77 @@ import {
|
|||
SelectValue,
|
||||
} from "@xtablo/ui/components/select";
|
||||
import { TypographyH3, TypographyH4 } from "@xtablo/ui/components/typography";
|
||||
import { Download, FolderInputIcon, PlusIcon, RefreshCcw } from "lucide-react";
|
||||
import {
|
||||
ClockIcon,
|
||||
Compass,
|
||||
Download,
|
||||
EllipsisVerticalIcon,
|
||||
Flame,
|
||||
FolderIcon,
|
||||
FolderInputIcon,
|
||||
Gem,
|
||||
Heart,
|
||||
Leaf,
|
||||
PlusIcon,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
Star,
|
||||
Sun,
|
||||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Outlet,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { useDeleteEvent, useEventsByTablo } from "../hooks/events";
|
||||
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
import { EventModal } from "../components/EventModal";
|
||||
|
||||
type ViewType = "month" | "week" | "day";
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500":
|
||||
return Zap;
|
||||
case "bg-green-500":
|
||||
return Leaf;
|
||||
case "bg-purple-500":
|
||||
return Gem;
|
||||
case "bg-red-500":
|
||||
return Flame;
|
||||
case "bg-yellow-500":
|
||||
return Star;
|
||||
case "bg-indigo-500":
|
||||
return Compass;
|
||||
case "bg-pink-500":
|
||||
return Heart;
|
||||
case "bg-teal-500":
|
||||
return Waves;
|
||||
case "bg-orange-500":
|
||||
return Sun;
|
||||
case "bg-cyan-500":
|
||||
return Sparkles;
|
||||
default:
|
||||
return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function getTabloIconColor(color: string | null | undefined): string {
|
||||
switch (color) {
|
||||
case "bg-yellow-500":
|
||||
case "bg-cyan-500":
|
||||
return "text-gray-700";
|
||||
default:
|
||||
return "text-white";
|
||||
}
|
||||
}
|
||||
|
||||
export const PlanningPage = () => {
|
||||
const { t } = useTranslation(["planning", "common"]);
|
||||
const { tablo_id } = useParams();
|
||||
|
|
@ -32,21 +100,27 @@ export const PlanningPage = () => {
|
|||
// Initialize view from URL search params, default to "month"
|
||||
const viewFromUrl = searchParams.get("view") as ViewType | null;
|
||||
const initialView: ViewType =
|
||||
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl) ? viewFromUrl : "month";
|
||||
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl)
|
||||
? viewFromUrl
|
||||
: "month";
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView);
|
||||
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(
|
||||
tablo_id || "all",
|
||||
);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isWebcalModalOpen, setIsWebcalModalOpen] = useState(false);
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
const currentTab = searchParams.get("tab") ?? "calendar";
|
||||
const [showAllEvents, setShowAllEvents] = useState(false);
|
||||
const [isCreateEventOpen, setIsCreateEventOpen] = useState(false);
|
||||
|
||||
// Fetch tablos
|
||||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
|
||||
// Fetch events for selected tablo or all tablos
|
||||
const { data: tabloEvents = [], isLoading: tabloEventsLoading } = useEventsByTablo(
|
||||
selectedTabloId !== "all" ? selectedTabloId : null
|
||||
);
|
||||
const { data: tabloEvents = [], isLoading: tabloEventsLoading } =
|
||||
useEventsByTablo(selectedTabloId !== "all" ? selectedTabloId : null);
|
||||
// Fetch all tablo accesses
|
||||
const { data: tabloAccess } = useGetAllTabloAccess();
|
||||
|
||||
|
|
@ -57,7 +131,7 @@ export const PlanningPage = () => {
|
|||
if (
|
||||
tabloAccess?.find(
|
||||
(access: { tablo_id: string; is_admin: boolean }) =>
|
||||
access.tablo_id === event.tablo_id && access.is_admin
|
||||
access.tablo_id === event.tablo_id && access.is_admin,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
|
|
@ -80,13 +154,17 @@ export const PlanningPage = () => {
|
|||
return newParams;
|
||||
});
|
||||
},
|
||||
[setSearchParams]
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// Sync view with URL on mount and when URL changes
|
||||
useEffect(() => {
|
||||
const viewParam = searchParams.get("view") as ViewType | null;
|
||||
if (viewParam && ["month", "week", "day"].includes(viewParam) && viewParam !== currentView) {
|
||||
if (
|
||||
viewParam &&
|
||||
["month", "week", "day"].includes(viewParam) &&
|
||||
viewParam !== currentView
|
||||
) {
|
||||
setCurrentView(viewParam);
|
||||
} else if (!viewParam) {
|
||||
// If no view param in URL, set it to current view
|
||||
|
|
@ -96,7 +174,7 @@ export const PlanningPage = () => {
|
|||
newParams.set("view", currentView);
|
||||
return newParams;
|
||||
},
|
||||
{ replace: true }
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [searchParams, currentView, setSearchParams]);
|
||||
|
|
@ -147,7 +225,8 @@ export const PlanningPage = () => {
|
|||
const calendarName =
|
||||
selectedTabloId === "all"
|
||||
? t("planning:allEvents")
|
||||
: tablos?.find((t) => t.id === selectedTabloId)?.name || t("planning:title");
|
||||
: tablos?.find((t) => t.id === selectedTabloId)?.name ||
|
||||
t("planning:title");
|
||||
|
||||
const icsContent = generateICSFromEvents(tabloEvents, calendarName);
|
||||
const filename =
|
||||
|
|
@ -172,6 +251,18 @@ export const PlanningPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const navigateToEditEvent = (event: EventAndTablo) => {
|
||||
if (!canEditEvent(event) || isReadOnly) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("tab", "events");
|
||||
|
||||
navigate({
|
||||
pathname: `/planning/${event.tablo_id}/events/${event.event_id}/edit`,
|
||||
search: `?${params.toString()}`,
|
||||
});
|
||||
};
|
||||
|
||||
const monthNames = [
|
||||
t("planning:months.january"),
|
||||
t("planning:months.february"),
|
||||
|
|
@ -264,7 +355,12 @@ export const PlanningPage = () => {
|
|||
// const nowMinute = now.getMinutes();
|
||||
const nowDay = now.getDate();
|
||||
|
||||
fullDate.setHours(Number(time.split(":")[0]), Number(time.split(":")[1]), 0, 0);
|
||||
fullDate.setHours(
|
||||
Number(time.split(":")[0]),
|
||||
Number(time.split(":")[1]),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
const hour = fullDate.getHours();
|
||||
// const minute = fullDate.getMinutes();
|
||||
|
|
@ -332,7 +428,8 @@ export const PlanningPage = () => {
|
|||
const daysInMonth = lastDay.getDate();
|
||||
// Adjust for Monday as first day of week
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
const mondayStartingDay = startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
|
||||
const mondayStartingDay =
|
||||
startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < mondayStartingDay; i++) {
|
||||
|
|
@ -369,7 +466,9 @@ export const PlanningPage = () => {
|
|||
if (currentView === "week") {
|
||||
const weekDays = getWeekDays();
|
||||
const weekDateStrings = weekDays.map(formatDate);
|
||||
return tabloEvents.filter((event) => weekDateStrings.includes(event.start_date));
|
||||
return tabloEvents.filter((event) =>
|
||||
weekDateStrings.includes(event.start_date),
|
||||
);
|
||||
} else if (currentView === "day") {
|
||||
const dateString = formatDate(currentDate);
|
||||
return tabloEvents.filter((event) => event.start_date === dateString);
|
||||
|
|
@ -390,7 +489,7 @@ export const PlanningPage = () => {
|
|||
...visibleEvents.map((event) => {
|
||||
const [hour] = event.start_time.split(":").map(Number);
|
||||
return hour;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Return the earlier of 8am or the earliest event hour
|
||||
|
|
@ -414,7 +513,7 @@ export const PlanningPage = () => {
|
|||
}
|
||||
const [hour] = event.end_time.split(":").map(Number);
|
||||
return hour;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Return the later of 7pm or the latest event hour
|
||||
|
|
@ -441,7 +540,7 @@ export const PlanningPage = () => {
|
|||
|
||||
return Array.from(
|
||||
{ length: numSlots },
|
||||
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`
|
||||
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -469,7 +568,9 @@ export const PlanningPage = () => {
|
|||
className={`min-h-[120px] border-b border-border ${
|
||||
(index + 1) % 7 !== 0 ? "border-r border-border" : ""
|
||||
} ${day ? "cursor-pointer hover:bg-muted" : "bg-muted"} ${
|
||||
day && formatDate(day) === formatDate(new Date()) ? "bg-primary/10" : ""
|
||||
day && formatDate(day) === formatDate(new Date())
|
||||
? "bg-primary/10"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (day) {
|
||||
|
|
@ -487,7 +588,9 @@ export const PlanningPage = () => {
|
|||
<div className="p-2">
|
||||
<div
|
||||
className={`text-sm font-medium mb-1 ${
|
||||
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
|
||||
formatDate(day) === formatDate(new Date())
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
|
|
@ -514,14 +617,18 @@ export const PlanningPage = () => {
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditEvent(event)) {
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
navigate(
|
||||
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="truncate">
|
||||
{formatTime(event.start_time)} {event.title}
|
||||
{selectedTabloId === "all" && event.tablo_name && (
|
||||
<span className="opacity-75 ml-1">• {event.tablo_name}</span>
|
||||
<span className="opacity-75 ml-1">
|
||||
• {event.tablo_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canDeleteEvent(event) && (
|
||||
|
|
@ -569,7 +676,9 @@ export const PlanningPage = () => {
|
|||
</div>
|
||||
<div
|
||||
className={`text-lg font-medium mt-1 ${
|
||||
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
|
||||
formatDate(day) === formatDate(new Date())
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
|
|
@ -611,10 +720,18 @@ export const PlanningPage = () => {
|
|||
)}
|
||||
|
||||
{getEventsForDate(day)
|
||||
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
|
||||
.filter((event) =>
|
||||
event.start_time.startsWith(time.split(":")[0]),
|
||||
)
|
||||
.map((event) => {
|
||||
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
|
||||
const eventOffset = calculateEventOffset(event.start_time, time);
|
||||
const eventHeight = calculateEventHeight(
|
||||
event.start_time,
|
||||
event.end_time,
|
||||
);
|
||||
const eventOffset = calculateEventOffset(
|
||||
event.start_time,
|
||||
time,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={event.event_id}
|
||||
|
|
@ -631,7 +748,7 @@ export const PlanningPage = () => {
|
|||
minHeight: "30px",
|
||||
}}
|
||||
title={`${formatTime(event.start_time)} - ${formatTime(
|
||||
event.end_time
|
||||
event.end_time,
|
||||
)} ${event.title}${
|
||||
selectedTabloId === "all" && event.tablo_name
|
||||
? ` - ${event.tablo_name}`
|
||||
|
|
@ -640,19 +757,24 @@ export const PlanningPage = () => {
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditEvent(event)) {
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
navigate(
|
||||
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium leading-tight">
|
||||
{event.title}
|
||||
{selectedTabloId === "all" && event.tablo_name && (
|
||||
<span className="opacity-75 ml-1">• {event.tablo_name}</span>
|
||||
<span className="opacity-75 ml-1">
|
||||
• {event.tablo_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{eventHeight >= 30 && (
|
||||
<div className="text-[9px] opacity-75 leading-tight">
|
||||
{formatTime(event.start_time)} - {formatTime(event.end_time)}
|
||||
{formatTime(event.start_time)} -{" "}
|
||||
{formatTime(event.end_time)}
|
||||
</div>
|
||||
)}
|
||||
{canDeleteEvent(event) && (
|
||||
|
|
@ -685,7 +807,9 @@ export const PlanningPage = () => {
|
|||
<div className="text-sm text-muted-foreground uppercase">
|
||||
{dayNames[currentDate.getDay()]}
|
||||
</div>
|
||||
<div className="text-2xl font-medium text-foreground mt-1">{currentDate.getDate()}</div>
|
||||
<div className="text-2xl font-medium text-foreground mt-1">
|
||||
{currentDate.getDate()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
|
|
@ -718,10 +842,18 @@ export const PlanningPage = () => {
|
|||
)}
|
||||
|
||||
{getEventsForDate(currentDate)
|
||||
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
|
||||
.filter((event) =>
|
||||
event.start_time.startsWith(time.split(":")[0]),
|
||||
)
|
||||
.map((event) => {
|
||||
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
|
||||
const eventOffset = calculateEventOffset(event.start_time, time);
|
||||
const eventHeight = calculateEventHeight(
|
||||
event.start_time,
|
||||
event.end_time,
|
||||
);
|
||||
const eventOffset = calculateEventOffset(
|
||||
event.start_time,
|
||||
time,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={event.event_id}
|
||||
|
|
@ -738,7 +870,7 @@ export const PlanningPage = () => {
|
|||
minHeight: "30px",
|
||||
}}
|
||||
title={`${formatTime(event.start_time)} - ${formatTime(
|
||||
event.end_time
|
||||
event.end_time,
|
||||
)} ${event.title}${
|
||||
selectedTabloId === "all" && event.tablo_name
|
||||
? ` - ${event.tablo_name}`
|
||||
|
|
@ -747,19 +879,24 @@ export const PlanningPage = () => {
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditEvent(event)) {
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
navigate(
|
||||
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium truncate leading-tight">
|
||||
{event.title}
|
||||
{selectedTabloId === "all" && event.tablo_name && (
|
||||
<span className="opacity-75 ml-1">• {event.tablo_name}</span>
|
||||
<span className="opacity-75 ml-1">
|
||||
• {event.tablo_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{eventHeight >= 30 && (
|
||||
<div className="text-[9px] opacity-75 leading-tight">
|
||||
{formatTime(event.start_time)} - {formatTime(event.end_time)}
|
||||
{formatTime(event.start_time)} -{" "}
|
||||
{formatTime(event.end_time)}
|
||||
</div>
|
||||
)}
|
||||
{eventHeight >= 75 && event.description && (
|
||||
|
|
@ -789,6 +926,226 @@ export const PlanningPage = () => {
|
|||
</div>
|
||||
);
|
||||
|
||||
// ── Events card view ──────────────────────────────────────────────────────
|
||||
const renderEventsView = () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const filtered = tabloEvents.filter((e) => {
|
||||
if (showAllEvents) return true;
|
||||
const eventDate = e.start_date
|
||||
? new Date(e.start_date + "T00:00:00")
|
||||
: null;
|
||||
return !eventDate || eventDate >= today;
|
||||
});
|
||||
|
||||
const months = [
|
||||
"JAN",
|
||||
"FÉV",
|
||||
"MAR",
|
||||
"AVR",
|
||||
"MAI",
|
||||
"JUN",
|
||||
"JUL",
|
||||
"AOÛ",
|
||||
"SEP",
|
||||
"OCT",
|
||||
"NOV",
|
||||
"DÉC",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-6 px-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{t("planning:events", "Événements")}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isReadOnly) setIsCreateEventOpen(true);
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-[#804EEC] hover:bg-[#6f3fd4] text-white rounded-xl transition-colors font-medium shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
<span>{t("planning:createEvent")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllEvents(false)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors border ${
|
||||
!showAllEvents
|
||||
? "bg-[#804EEC] text-white border-[#804EEC]"
|
||||
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
À venir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllEvents(true)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors border ${
|
||||
showAllEvents
|
||||
? "bg-[#804EEC] text-white border-[#804EEC]"
|
||||
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
Tous les événements
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Event cards grid */}
|
||||
{tabloEventsLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<img
|
||||
src="/icon.jpg"
|
||||
alt="Loading..."
|
||||
className="animate-spin rounded-full h-8 w-8 object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
|
||||
Aucun événement trouvé
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
{showAllEvents
|
||||
? "Aucun événement trouvé"
|
||||
: "Aucun événement à venir"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{filtered.map((event) => {
|
||||
const date = event.start_date
|
||||
? new Date(event.start_date + "T00:00:00")
|
||||
: null;
|
||||
const monthLabel = date ? months[date.getMonth()] : "";
|
||||
const dayLabel = date
|
||||
? String(date.getDate()).padStart(2, "0")
|
||||
: "";
|
||||
const TabloIcon = getTabloIcon(event.tablo_color);
|
||||
const iconColor = getTabloIconColor(event.tablo_color);
|
||||
const timeLabel = event.start_time
|
||||
? `${event.start_time.slice(0, 5)}${event.end_time ? ` – ${event.end_time.slice(0, 5)}` : ""}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-all hover:border-gray-300 dark:hover:border-gray-600 relative group"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-6 right-6 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
aria-label="Options de l'événement"
|
||||
>
|
||||
<EllipsisVerticalIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigateToEditEvent(event)}
|
||||
disabled={!canEditEvent(event) || isReadOnly}
|
||||
>
|
||||
Replanifier
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteEvent.mutate(event.event_id)}
|
||||
disabled={
|
||||
!canDeleteEvent(event) ||
|
||||
isReadOnly ||
|
||||
deleteEvent.isPending
|
||||
}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
{/* Date badge */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-[8px] bg-[#F4F3FF] dark:bg-purple-900/20 flex flex-col items-center justify-center">
|
||||
<span className="text-[10px] font-semibold text-[#7F56D9] dark:text-purple-400 uppercase tracking-wide leading-none">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-[#7F56D9] dark:text-purple-400 leading-none mt-0.5">
|
||||
{dayLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="flex-1 min-w-0 pt-1 pr-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 leading-tight">
|
||||
{event.title}
|
||||
</h3>
|
||||
{event.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="space-y-2.5 mt-3">
|
||||
{timeLabel && (
|
||||
<div className="flex items-center gap-2.5 text-[#344054] dark:text-gray-300">
|
||||
<ClockIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-sm">{timeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{event.tablo_name && (
|
||||
<div className="flex items-center gap-2.5 text-[#344054] dark:text-gray-300">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-[5px] flex-shrink-0 flex items-center justify-center ${event.tablo_color || "bg-gray-400"}`}
|
||||
>
|
||||
<TabloIcon className={`w-3 h-3 ${iconColor}`} />
|
||||
</div>
|
||||
<span className="text-sm truncate">
|
||||
{event.tablo_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (currentTab === "events") {
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4">
|
||||
{renderEventsView()}
|
||||
<EventModal
|
||||
mode="create"
|
||||
isOpen={isCreateEventOpen}
|
||||
onClose={() => setIsCreateEventOpen(false)}
|
||||
defaultTabloId={
|
||||
selectedTabloId !== "all" ? selectedTabloId : undefined
|
||||
}
|
||||
defaultDate={currentDate}
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex">
|
||||
|
|
@ -802,10 +1159,15 @@ export const PlanningPage = () => {
|
|||
onValueChange={(value) => setSelectedTabloId(value)}
|
||||
disabled={tablosLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t("planning:selectTablo")}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
|
||||
tablosLoading
|
||||
? t("common:actions.loading")
|
||||
: t("planning:selectTablo")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
|
@ -830,19 +1192,21 @@ export const PlanningPage = () => {
|
|||
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedTabloId === "all") {
|
||||
navigate(`/planning/create?date=${currentDate.toISOString()}`);
|
||||
navigate(
|
||||
`/planning/create?date=${currentDate.toISOString()}`,
|
||||
);
|
||||
} else {
|
||||
navigate(
|
||||
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
|
||||
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
className="w-full bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
|
|
@ -859,7 +1223,7 @@ export const PlanningPage = () => {
|
|||
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -892,7 +1256,10 @@ export const PlanningPage = () => {
|
|||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-xs">
|
||||
{dayNamesShort.map((day) => (
|
||||
<div key={day} className="text-center text-muted-foreground p-1">
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-muted-foreground p-1"
|
||||
>
|
||||
{day.slice(0, 1)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -903,7 +1270,7 @@ export const PlanningPage = () => {
|
|||
day ? "hover:bg-muted" : ""
|
||||
} ${
|
||||
day && formatDate(day) === formatDate(new Date())
|
||||
? "bg-primary text-primary-foreground"
|
||||
? "bg-[#804EEC] text-white"
|
||||
: day
|
||||
? "text-foreground"
|
||||
: ""
|
||||
|
|
@ -928,12 +1295,25 @@ export const PlanningPage = () => {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<TypographyH3>{t("planning:title")}</TypographyH3>
|
||||
<Button onClick={goToToday} variant="outline" size="sm">
|
||||
<Button
|
||||
onClick={goToToday}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10"
|
||||
>
|
||||
{t("planning:today")}
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<button
|
||||
onClick={() => navigateDate(-1)}
|
||||
className="p-2 hover:bg-muted rounded"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -942,8 +1322,16 @@ export const PlanningPage = () => {
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<button
|
||||
onClick={() => navigateDate(1)}
|
||||
className="p-2 hover:bg-muted rounded"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -974,7 +1362,7 @@ export const PlanningPage = () => {
|
|||
title={t(`planning:views.${view}Title`)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors capitalize ${
|
||||
currentView === view
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
? "bg-white dark:bg-gray-800 text-[#804EEC] shadow-sm font-semibold"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -998,7 +1386,9 @@ export const PlanningPage = () => {
|
|||
alt="Loading..."
|
||||
className="animate-spin rounded-full h-8 w-8 object-cover"
|
||||
/>
|
||||
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t("planning:loadingEvents")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -1013,9 +1403,14 @@ export const PlanningPage = () => {
|
|||
|
||||
<Outlet />
|
||||
|
||||
{isImportModalOpen && <ImportICSModal onClose={() => setIsImportModalOpen(false)} />}
|
||||
{isImportModalOpen && (
|
||||
<ImportICSModal onClose={() => setIsImportModalOpen(false)} />
|
||||
)}
|
||||
|
||||
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
|
||||
<WebcalModal
|
||||
open={isWebcalModalOpen}
|
||||
onOpenChange={setIsWebcalModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
384
apps/main/src/pages/signup-v2.tsx
Normal file
384
apps/main/src/pages/signup-v2.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import { AnimatedBackground } from "@ui/components/AnimatedBackground";
|
||||
import { LoginWithGoogle } from "@ui/components/BrandButtons/LoginWithGoogle";
|
||||
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { FieldError } from "@xtablo/ui/components/field";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSignUp } from "../hooks/auth";
|
||||
|
||||
export function SignUpV2Page() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const redirectUrl = localStorage.getItem("redirectUrl");
|
||||
const { mutate: signUp, isPending } = useSignUp({
|
||||
redirectUrl: redirectUrl ?? null,
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
username: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
business_name: "",
|
||||
});
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "light") {
|
||||
setTheme("dark");
|
||||
} else if (theme === "dark") {
|
||||
setTheme("system");
|
||||
} else {
|
||||
setTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
const getThemeIcon = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <SunIcon className="w-5 h-5" />;
|
||||
case "dark":
|
||||
return <MoonIcon className="w-5 h-5" />;
|
||||
case "system":
|
||||
return <MonitorIcon className="w-5 h-5" />;
|
||||
default:
|
||||
return <SunIcon className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const nextErrors: Record<string, string> = {};
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
nextErrors.password = t("auth:signup.errors.passwordLength");
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
nextErrors.confirmPassword = t("auth:signup.errors.passwordMatch");
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(formData.email)) {
|
||||
nextErrors.email = t("auth:signup.errors.invalidEmail");
|
||||
}
|
||||
|
||||
if (!termsAccepted) {
|
||||
nextErrors.terms = t("auth:signup.errors.termsRequired");
|
||||
}
|
||||
|
||||
return Object.keys(nextErrors).length === 0 ? null : nextErrors;
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationErrors = validateForm();
|
||||
if (validationErrors) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
signUp({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
confirm_password: formData.confirmPassword,
|
||||
business_name: formData.business_name,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-background text-foreground">
|
||||
<AnimatedBackground />
|
||||
|
||||
<div className="relative z-10 min-h-screen md:grid md:grid-cols-[minmax(0,640px)_1fr]">
|
||||
<main className="flex min-h-screen items-center px-6 py-10 md:px-10 lg:px-14">
|
||||
<div className="w-full max-w-lg mx-auto">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<a
|
||||
href="https://www.xtablo.com"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t("auth:common.backHome")}
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("auth:common.themeToggle", { theme })}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<img
|
||||
src="/logo_dark.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo_white.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("auth:signup.title")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mb-7">
|
||||
{t("auth:signup.alreadyAccount")}{" "}
|
||||
<Link
|
||||
to="/login-v2"
|
||||
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
|
||||
>
|
||||
{t("auth:signup.loginLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">
|
||||
{t("auth:signup.firstName")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, first_name: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:signup.firstNamePlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.first_name && (
|
||||
<FieldError errors={[{ message: errors.first_name }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">
|
||||
{t("auth:signup.lastName")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, last_name: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:signup.lastNamePlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.last_name && (
|
||||
<FieldError errors={[{ message: errors.last_name }]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("auth:signup.email")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:signup.emailPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.email && (
|
||||
<FieldError errors={[{ message: errors.email }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common:labels.password")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:signup.passwordPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.password && (
|
||||
<FieldError errors={[{ message: errors.password }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
{t("auth:signup.confirmPassword")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
confirmPassword: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
placeholder={t("auth:signup.confirmPasswordPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.confirmPassword && (
|
||||
<FieldError errors={[{ message: errors.confirmPassword }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
name="terms"
|
||||
checked={termsAccepted}
|
||||
onChange={(e) => setTermsAccepted(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border border-border bg-background"
|
||||
required
|
||||
/>
|
||||
<Label
|
||||
htmlFor="terms"
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{t("auth:signup.termsAccept")}{" "}
|
||||
<Link
|
||||
to="/legal-notice"
|
||||
target="_blank"
|
||||
className="text-foreground hover:text-foreground/80 underline"
|
||||
>
|
||||
{t("auth:signup.termsLink")}
|
||||
</Link>{" "}
|
||||
{t("auth:signup.termsAnd")}{" "}
|
||||
<Link
|
||||
to="/privacy-policy"
|
||||
target="_blank"
|
||||
className="text-foreground hover:text-foreground/80 underline"
|
||||
>
|
||||
{t("auth:signup.privacyLink")}
|
||||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
{errors?.terms && (
|
||||
<FieldError errors={[{ message: errors.terms }]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
type="submit"
|
||||
>
|
||||
{isPending
|
||||
? t("auth:common.creatingAccount")
|
||||
: t("auth:signup.signupButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-3 bg-background text-xs text-muted-foreground">
|
||||
{t("auth:common.orContinue")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginWithGoogle />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="hidden md:flex min-h-screen items-center justify-center px-8 lg:px-14 border-l border-border bg-gradient-to-br from-[#F5F0FF] via-[#ECE4FF] to-[#DCCEFF] dark:from-[#201933] dark:via-[#271F3E] dark:to-[#2F2548]">
|
||||
<div className="relative w-full max-w-xl rounded-3xl border border-white/50 dark:border-white/10 bg-white/80 dark:bg-[#171224]/85 backdrop-blur-sm shadow-2xl p-10">
|
||||
<SparklesIcon className="absolute top-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
<SparklesIcon className="absolute top-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
<SparklesIcon className="absolute bottom-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
<SparklesIcon className="absolute bottom-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
|
||||
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-20 h-20 rounded-full border border-border bg-card flex items-center justify-center shadow-sm">
|
||||
<img
|
||||
src="/logo_dark.png"
|
||||
alt="Xtablo"
|
||||
className="w-12 h-12 object-contain block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo_white.png"
|
||||
alt="Xtablo"
|
||||
className="w-12 h-12 object-contain hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-center mb-3">
|
||||
{t("auth:signup.asideTitle")}
|
||||
</h2>
|
||||
<p className="text-center text-muted-foreground mb-8">
|
||||
{t("auth:signup.asideDescription")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
|
||||
{t("auth:signup.feature1")}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
|
||||
{t("auth:signup.feature2")}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
|
||||
{t("auth:signup.feature3")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,38 +11,73 @@ import {
|
|||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from "@xtablo/ui/components/empty";
|
||||
// shadcn components
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@xtablo/ui/components/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip";
|
||||
import { Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@xtablo/ui/components/tooltip";
|
||||
import { Text } from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Compass,
|
||||
Flame,
|
||||
FolderIcon,
|
||||
Gem,
|
||||
Heart,
|
||||
Leaf,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
Sun,
|
||||
Trash2,
|
||||
Users,
|
||||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
import {
|
||||
useCanCreateTablo,
|
||||
useCreateTablo,
|
||||
useDeleteTablo,
|
||||
useTablosList,
|
||||
} from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { DashboardActionCards } from "src/components/DashboardActionCards";
|
||||
import { DashboardTaskList } from "src/components/DashboardTaskList";
|
||||
import { TaskModal } from "src/components/kanban/TaskModal";
|
||||
import { ProjectCardList } from "src/components/ProjectCardList";
|
||||
|
||||
type FilterOption = {
|
||||
id: "all" | "todo" | "inProgress" | "done";
|
||||
name: string;
|
||||
};
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function getTabloIconColor(color: string | null | undefined): string {
|
||||
switch (color) {
|
||||
case "bg-yellow-500":
|
||||
case "bg-cyan-500":
|
||||
return "text-gray-700";
|
||||
default:
|
||||
return "text-white";
|
||||
}
|
||||
}
|
||||
|
||||
export const TabloPage = () => {
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
|
@ -55,48 +90,53 @@ export const TabloPage = () => {
|
|||
y: number;
|
||||
} | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [filterType, setFilterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
|
||||
const [filterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isReadOnlyUser = useIsReadOnlyUser();
|
||||
const canCreateTablo = useCanCreateTablo();
|
||||
|
||||
const user = useUser();
|
||||
const isReadOnly = isReadOnlyUser || !canCreateTablo;
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return t("pages:tablo.greeting.morning", "Good Morning");
|
||||
if (hour < 18) return t("pages:tablo.greeting.afternoon", "Good Afternoon");
|
||||
return t("pages:tablo.greeting.evening", "Good Evening");
|
||||
};
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date());
|
||||
|
||||
// Get view mode from URL params, default to "list"
|
||||
const viewMode = (searchParams.get("view") as "grid" | "list") || "list";
|
||||
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
|
||||
|
||||
const filterOptions: FilterOption[] = [
|
||||
{ id: "all", name: t("pages:tablo.filter.all") },
|
||||
{ id: "todo", name: t("pages:tablo.filter.todo") },
|
||||
{ id: "inProgress", name: t("pages:tablo.filter.inProgress") },
|
||||
{ id: "done", name: t("pages:tablo.filter.done") },
|
||||
];
|
||||
|
||||
// Function to update view mode in URL
|
||||
const setViewMode = (mode: "grid" | "list") => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("view", mode);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const { data: tablos, isLoading, error } = useTablosList();
|
||||
const createTabloMutation = useCreateTablo();
|
||||
// const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
const { mutateAsync: deleteTablo } = useDeleteTablo();
|
||||
|
||||
// Filter tablos based on status
|
||||
// Filter tablos based on status and search query
|
||||
const filteredTablos = tablos?.filter((tablo) => {
|
||||
if (filterType === "todo") {
|
||||
return tablo.status === "todo";
|
||||
} else if (filterType === "inProgress") {
|
||||
return tablo.status === "inProgress";
|
||||
} else if (filterType === "done") {
|
||||
return tablo.status === "done";
|
||||
}
|
||||
return true; // 'all' case
|
||||
const matchesStatus =
|
||||
filterType === "all" ||
|
||||
(filterType === "todo" && tablo.status === "todo") ||
|
||||
(filterType === "inProgress" && tablo.status === "inProgress") ||
|
||||
(filterType === "done" && tablo.status === "done");
|
||||
|
||||
const matchesSearch =
|
||||
!searchQuery || tablo.name.toLowerCase().includes(searchQuery);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
const menuItems = [
|
||||
|
|
@ -106,7 +146,8 @@ export const TabloPage = () => {
|
|||
},
|
||||
{
|
||||
name: "Membres",
|
||||
action: (tabloId: string) => navigate(`/tablos/${tabloId}?section=members`),
|
||||
action: (tabloId: string) =>
|
||||
navigate(`/tablos/${tabloId}?section=members`),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -115,10 +156,11 @@ export const TabloPage = () => {
|
|||
toast.add(
|
||||
{
|
||||
title: t("common:error"),
|
||||
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de tablo.",
|
||||
description:
|
||||
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de projet.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -130,7 +172,7 @@ export const TabloPage = () => {
|
|||
};
|
||||
|
||||
const createNewTablo = async (
|
||||
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">
|
||||
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">,
|
||||
) => {
|
||||
try {
|
||||
await createTabloMutation.mutateAsync(tabloData);
|
||||
|
|
@ -199,7 +241,9 @@ export const TabloPage = () => {
|
|||
};
|
||||
|
||||
const getUserRole = (tablo: UserTablo) => {
|
||||
return tablo.is_admin ? t("pages:tablo.role.admin") : t("pages:tablo.role.guest");
|
||||
return tablo.is_admin
|
||||
? t("pages:tablo.role.admin")
|
||||
: t("pages:tablo.role.guest");
|
||||
};
|
||||
|
||||
const getRoleColor = (tablo: UserTablo) => {
|
||||
|
|
@ -212,11 +256,14 @@ export const TabloPage = () => {
|
|||
|
||||
const totalTablos = tablos.length;
|
||||
const todoCount = tablos.filter((t) => t.status === "todo").length;
|
||||
const inProgressCount = tablos.filter((t) => t.status === "inProgress").length;
|
||||
const inProgressCount = tablos.filter(
|
||||
(t) => t.status === "inProgress",
|
||||
).length;
|
||||
const doneCount = tablos.filter((t) => t.status === "done").length;
|
||||
const adminCount = tablos.filter((t) => t.is_admin).length;
|
||||
const guestCount = tablos.filter((t) => !t.is_admin).length;
|
||||
const completionRate = totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
|
||||
const completionRate =
|
||||
totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalTablos,
|
||||
|
|
@ -235,9 +282,15 @@ export const TabloPage = () => {
|
|||
const isCreateDisabled = createTabloMutation.isPending || isReadOnly;
|
||||
|
||||
const button = (
|
||||
<Button id="create-tablo-button" onClick={openCreateModal} disabled={isCreateDisabled}>
|
||||
<Button
|
||||
id="create-tablo-button"
|
||||
onClick={openCreateModal}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
<Plus />
|
||||
{createTabloMutation.isPending ? t("common:actions.saving") : t("pages:tablo.createButton")}
|
||||
{createTabloMutation.isPending
|
||||
? t("common:actions.saving")
|
||||
: t("pages:tablo.createButton")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
|
|
@ -254,9 +307,15 @@ export const TabloPage = () => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnlyUser ? (
|
||||
<p>Vous ne pouvez pas créer de tablo car vous êtes en mode lecture seule.</p>
|
||||
<p>
|
||||
Vous ne pouvez pas créer de tablo car vous êtes en mode lecture
|
||||
seule.
|
||||
</p>
|
||||
) : (
|
||||
<p>Vous ne pouvez pas créer de tablo car vous avez atteint votre limite de tablos.</p>
|
||||
<p>
|
||||
Vous ne pouvez pas créer de tablo car vous avez atteint votre
|
||||
limite de tablos.
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -271,8 +330,12 @@ export const TabloPage = () => {
|
|||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
|
||||
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
{t("pages:tablo.title")}
|
||||
</h1>
|
||||
<Text className="text-muted-foreground mt-1">
|
||||
{t("pages:tablo.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
{createTabloButton()}
|
||||
</div>
|
||||
|
|
@ -295,11 +358,15 @@ export const TabloPage = () => {
|
|||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
|
||||
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
{t("pages:tablo.title")}
|
||||
</h1>
|
||||
<Text className="text-muted-foreground mt-1">
|
||||
{t("pages:tablo.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
<Button onClick={openCreateModal} disabled={isReadOnly}>
|
||||
<Plus /> Nouveau tablo
|
||||
<Plus /> Nouveau projet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -307,9 +374,13 @@ export const TabloPage = () => {
|
|||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-center items-center min-h-64">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive mb-2">Erreur lors du chargement des tablos</p>
|
||||
<p className="text-destructive mb-2">
|
||||
Erreur lors du chargement des projets
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{error instanceof Error ? error.message : "Une erreur inconnue s'est produite"}
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Une erreur inconnue s'est produite"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -320,6 +391,8 @@ export const TabloPage = () => {
|
|||
|
||||
const renderTablo = (tablo: UserTablo) => {
|
||||
const isAdmin = tablo.is_admin;
|
||||
const TabloIcon = getTabloIcon(tablo.color);
|
||||
const iconColor = getTabloIconColor(tablo.color);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -335,7 +408,9 @@ export const TabloPage = () => {
|
|||
>
|
||||
<div
|
||||
className={`bg-card rounded-lg shadow-lg transition-all duration-300 w-56 overflow-hidden border border-border ${
|
||||
isAdmin ? "hover:shadow-xl cursor-pointer" : "hover:shadow-xl cursor-pointer opacity-75"
|
||||
isAdmin
|
||||
? "hover:shadow-xl cursor-pointer"
|
||||
: "hover:shadow-xl cursor-pointer opacity-75"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -345,14 +420,18 @@ export const TabloPage = () => {
|
|||
{/* Image or Color */}
|
||||
<div className="relative h-40 group">
|
||||
{tablo.image ? (
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-full h-full ${
|
||||
tablo.color || "bg-gray-400"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
<h3 className="text-white font-bold text-xl text-center px-4">{tablo.name}</h3>
|
||||
<TabloIcon className={`w-12 h-12 ${iconColor}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -368,17 +447,21 @@ export const TabloPage = () => {
|
|||
<div className="p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
|
||||
<h3 className="text-foreground font-semibold text-base truncate">
|
||||
{tablo.name}
|
||||
</h3>
|
||||
{/* Status badge */}
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
|
||||
tablo.status
|
||||
tablo.status,
|
||||
)} shrink-0`}
|
||||
>
|
||||
<span>{getStatusLabel(tablo.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}>
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>{getUserRole(tablo)}</span>
|
||||
</div>
|
||||
|
|
@ -454,140 +537,6 @@ export const TabloPage = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderTabloListView = (tablo: UserTablo) => {
|
||||
const isAdmin = tablo.is_admin;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tablo.id}
|
||||
className="relative"
|
||||
data-tablo-id={tablo.id}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenuTablo(contextMenuTablo === tablo.id ? null : tablo.id);
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`bg-card rounded-lg shadow-md transition-all duration-300 overflow-hidden border border-border ${
|
||||
isAdmin ? "hover:shadow-lg cursor-pointer" : "hover:shadow-lg cursor-pointer opacity-75"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTablo(tablo.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center p-4 gap-4">
|
||||
{/* Image or Color - smaller in list view */}
|
||||
<div className="relative h-16 w-16 shrink-0 rounded-lg overflow-hidden group">
|
||||
{tablo.image ? (
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className={`w-full h-full ${
|
||||
tablo.color || "bg-gray-400"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
<span className="text-white font-bold text-sm">
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
|
||||
tablo.status
|
||||
)} shrink-0`}
|
||||
>
|
||||
<span>{getStatusLabel(tablo.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(
|
||||
tablo
|
||||
)} mt-1`}
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>{getUserRole(tablo)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Quick action buttons */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat/${tablo.id}`);
|
||||
}}
|
||||
title="Discussions"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 color-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/tablos/${tablo.id}?section=members`);
|
||||
}}
|
||||
title="Members"
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="p-2 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteTablo(tablo.id);
|
||||
}}
|
||||
title={t("pages:tablo.contextMenu.delete")}
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contextual Menu - same as grid view */}
|
||||
{contextMenuTablo === tablo.id && contextMenuPosition && (
|
||||
<div
|
||||
className="fixed bg-card rounded-lg shadow-lg border border-border py-2 z-30 min-w-36"
|
||||
style={{
|
||||
left: contextMenuPosition.x,
|
||||
top: contextMenuPosition.y,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.action(tablo.id);
|
||||
}}
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
|
|
@ -596,64 +545,18 @@ export const TabloPage = () => {
|
|||
setContextMenuPosition(null);
|
||||
}}
|
||||
>
|
||||
<header className="bg-card shadow-sm border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<TypographyH3>{t("pages:tablo.title")}</TypographyH3>
|
||||
<TypographyMuted>{t("pages:tablo.subtitle")}</TypographyMuted>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filter Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filterType}
|
||||
onValueChange={(value) =>
|
||||
setFilterType(value as "all" | "todo" | "inProgress" | "done")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-36 h-8">
|
||||
<SelectValue placeholder="Filtrer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{t(`pages:tablo.filter.${option.id}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 border border-border">
|
||||
<button
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
viewMode === "grid"
|
||||
? "bg-background text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title={t("pages:tablo.view.grid")}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
viewMode === "list"
|
||||
? "bg-background text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title={t("pages:tablo.view.list")}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createTabloButton()}
|
||||
</div>
|
||||
</div>
|
||||
<header className="px-6 pt-6 pb-4">
|
||||
<p className="text-base text-[#475467] dark:text-gray-400 mb-2 font-medium">
|
||||
{formattedDate}
|
||||
</p>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<h1 className="text-[24px] font-medium text-[#475467] dark:text-gray-400">
|
||||
{getGreeting()},{" "}
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||
{user.first_name ?? user.name}
|
||||
</span>
|
||||
!
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
|
|
@ -665,8 +568,12 @@ export const TabloPage = () => {
|
|||
<div className="bg-card rounded-lg shadow-md p-4 border border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.totalTablos}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Total
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.totalTablos}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
|
|
@ -681,7 +588,9 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.todo")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.todoCount}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.todoCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<ListTodo className="w-5 h-5 text-muted-foreground" />
|
||||
|
|
@ -713,7 +622,9 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.done")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.doneCount}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.doneCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-secondary-foreground" />
|
||||
|
|
@ -745,7 +656,9 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.admin")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.adminCount}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.adminCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
|
|
@ -760,7 +673,9 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.guest")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.guestCount}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.guestCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Users className="w-5 h-5 text-muted-foreground" />
|
||||
|
|
@ -771,25 +686,32 @@ export const TabloPage = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<DashboardActionCards
|
||||
onCreateProject={openCreateModal}
|
||||
onCreateTask={() => setIsTaskModalOpen(true)}
|
||||
onSendMessage={() => navigate("/chat")}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{filteredTablos && filteredTablos.length > 0 ? (
|
||||
viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{/* Render tablos in grid view */}
|
||||
{filteredTablos.map((tablo) => renderTablo(tablo))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Render tablos in list view */}
|
||||
{filteredTablos.map((tablo) => renderTabloListView(tablo))}
|
||||
</div>
|
||||
<ProjectCardList
|
||||
tablos={filteredTablos}
|
||||
onTabloClick={openTablo}
|
||||
onTabloMenuClick={handleDeleteTablo}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{t("pages:tablo.emptyState.title")}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{filterType === "all" && t("pages:tablo.emptyState.description")}
|
||||
{filterType === "all" &&
|
||||
t("pages:tablo.emptyState.description")}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{filterType === "all" && (
|
||||
|
|
@ -807,11 +729,16 @@ export const TabloPage = () => {
|
|||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DashboardTaskList />
|
||||
</main>
|
||||
|
||||
{/* Create Tablo Modal */}
|
||||
{isCreateModalOpen && (
|
||||
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
|
||||
<CreateTabloModal
|
||||
onClose={closeCreateModal}
|
||||
onCreate={createNewTablo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Tablo Modal */}
|
||||
|
|
@ -824,12 +751,14 @@ export const TabloPage = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Tutorial - Hidden */}
|
||||
{/* <TabloTutorial
|
||||
isOpen={isTutorialOpen}
|
||||
onClose={handleCloseTutorial}
|
||||
onCreateTablo={handleTutorialCreateTablo}
|
||||
/> */}
|
||||
{/* Create Task Modal */}
|
||||
<TaskModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={() => setIsTaskModalOpen(false)}
|
||||
tablos={tablos}
|
||||
allowTabloSelection={true}
|
||||
initialStatus="todo"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
419
apps/main/src/pages/tablos.tsx
Normal file
419
apps/main/src/pages/tablos.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import { cn } from "@xtablo/shared";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import {
|
||||
CalendarIcon,
|
||||
Compass,
|
||||
FilterIcon,
|
||||
Flame,
|
||||
FolderIcon,
|
||||
Gem,
|
||||
Grid3x3Icon,
|
||||
Heart,
|
||||
Leaf,
|
||||
ListIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
Sparkles,
|
||||
Star,
|
||||
Sun,
|
||||
Trash2Icon,
|
||||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { CreateTabloModal } from "../components/CreateTabloModal";
|
||||
import { DeleteTabloModal } from "../components/DeleteTabloModal";
|
||||
import { useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
|
||||
|
||||
// ─── Icon helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function getTabloIconColor(color: string | null | undefined): string {
|
||||
switch (color) {
|
||||
case "bg-yellow-500":
|
||||
case "bg-cyan-500":
|
||||
return "text-gray-700";
|
||||
default:
|
||||
return "text-white";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function getStatusConfig(status: string) {
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return {
|
||||
label: "En cours",
|
||||
badgeClass: "bg-[#FFF4E2] text-[#DB9729] border border-[#DB9729]",
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(dateStr));
|
||||
}
|
||||
|
||||
// ─── Card view ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabloCard({
|
||||
tablo,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: {
|
||||
tablo: UserTablo;
|
||||
onClick: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("pages");
|
||||
const { label, badgeClass, progress } = getStatusConfig(tablo.status);
|
||||
const TabloIcon = getTabloIcon(tablo.color);
|
||||
const iconColor = getTabloIconColor(tablo.color);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => onClick(tablo.id)}
|
||||
>
|
||||
{/* Status + delete */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Icon + name */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden",
|
||||
!tablo.image && (tablo.color || "bg-gray-400")
|
||||
)}
|
||||
>
|
||||
{tablo.image ? (
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<TabloIcon className={cn("w-4 h-4", iconColor)} />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex-1 line-clamp-2">
|
||||
{tablo.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400 mb-4">
|
||||
<CalendarIcon className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm">{formatDate(tablo.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{t("tablo.card.progress")} :</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-dashed border-[#D0D5DD] dark:border-gray-600">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Créé le <span className="font-semibold text-gray-900 dark:text-gray-100">{formatDate(tablo.created_at)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── List row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabloRow({
|
||||
tablo,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: {
|
||||
tablo: UserTablo;
|
||||
onClick: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { label, badgeClass, progress } = getStatusConfig(tablo.status);
|
||||
const TabloIcon = getTabloIcon(tablo.color);
|
||||
const iconColor = getTabloIconColor(tablo.color);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="border-t border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
onClick={() => onClick(tablo.id)}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden",
|
||||
!tablo.image && (tablo.color || "bg-gray-400")
|
||||
)}
|
||||
>
|
||||
{tablo.image ? (
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<TabloIcon className={cn("w-4 h-4", iconColor)} />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>{label}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarIcon className="w-4 h-4 shrink-0" />
|
||||
{formatDate(tablo.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 min-w-[80px]">
|
||||
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right">{progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors p-1 rounded"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TablosPage() {
|
||||
const { t } = useTranslation("pages");
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
|
||||
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [deleteTabloId, setDeleteTabloId] = useState<string | null>(null);
|
||||
|
||||
const { data: tablos = [], isLoading } = useTablosList();
|
||||
const createTablo = useCreateTablo();
|
||||
const { mutateAsync: deleteTablo, isPending: isDeleting } = useDeleteTablo();
|
||||
|
||||
const deleteTarget = tablos.find((t) => t.id === deleteTabloId) ?? null;
|
||||
|
||||
const filteredTablos = tablos.filter((tablo) => {
|
||||
const matchesSearch = !searchQuery || tablo.name.toLowerCase().includes(searchQuery);
|
||||
const matchesStatus = statusFilter === "all" || tablo.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const statusFilters = [
|
||||
{ value: "all", label: t("tablo.filter.all") },
|
||||
{ value: "todo", label: t("tablo.filter.todo") },
|
||||
{ value: "in_progress", label: t("tablo.filter.inProgress") },
|
||||
{ value: "done", label: t("tablo.filter.done") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-8 pb-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{t("tablo.projectList.title")}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
{t("tablo.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View tabs */}
|
||||
<div className="flex items-center gap-6 mb-6 border-b border-[#EAECF0] dark:border-gray-700">
|
||||
{[
|
||||
{ id: "card" as const, label: t("tablo.view.grid"), Icon: Grid3x3Icon },
|
||||
{ id: "list" as const, label: t("tablo.view.list"), Icon: ListIcon },
|
||||
].map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setViewMode(id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 pb-3 border-b-2 transition-colors",
|
||||
viewMode === id
|
||||
? "border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400 font-semibold"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search + status filter */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative md:w-[350px]">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={searchQuery}
|
||||
placeholder="Rechercher..."
|
||||
className="w-full pl-10 pr-4 py-3 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{statusFilters.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-2.5 border rounded-[8px] font-medium text-sm transition-colors",
|
||||
statusFilter === f.value
|
||||
? "border-purple-600 bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400"
|
||||
: "border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
>
|
||||
{f.value === "all" && <FilterIcon className="w-4 h-4" />}
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : filteredTablos.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<Grid3x3Icon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucun projet trouvé</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
{searchQuery ? "Essayez un autre terme de recherche" : "Créez votre premier projet"}
|
||||
</p>
|
||||
</div>
|
||||
) : viewMode === "card" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{filteredTablos.map((tablo) => (
|
||||
<TabloCard
|
||||
key={tablo.id}
|
||||
tablo={tablo}
|
||||
onClick={(id) => navigate(`/tablos/${id}`)}
|
||||
onDelete={setDeleteTabloId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Projet</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Statut</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Créé le</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Progression</th>
|
||||
<th className="px-6 py-3 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTablos.map((tablo) => (
|
||||
<TabloRow
|
||||
key={tablo.id}
|
||||
tablo={tablo}
|
||||
onClick={(id) => navigate(`/tablos/${id}`)}
|
||||
onDelete={setDeleteTabloId}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreateModal && (
|
||||
<CreateTabloModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreate={(tabloData) => {
|
||||
createTablo.mutate({ ...tabloData, status: "todo" });
|
||||
setShowCreateModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete modal */}
|
||||
<DeleteTabloModal
|
||||
tablo={deleteTarget}
|
||||
onClose={() => setDeleteTabloId(null)}
|
||||
onConfirm={async (id) => {
|
||||
await deleteTablo(id);
|
||||
setDeleteTabloId(null);
|
||||
}}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@
|
|||
"./database": "./src/database.types.ts",
|
||||
"./events": "./src/events.types.ts",
|
||||
"./tablos": "./src/tablos.types.ts",
|
||||
"./tablo-data": "./src/tablo-data.types.ts",
|
||||
"./stripe": "./src/stripe.types.ts",
|
||||
"./kanban": "./src/kanban.types.ts",
|
||||
"./utils": "./src/utils.ts"
|
||||
|
|
|
|||
|
|
@ -607,6 +607,7 @@ export type Database = {
|
|||
assignee_id: string | null;
|
||||
created_at: string;
|
||||
description: string | null;
|
||||
due_date: string | null;
|
||||
id: string;
|
||||
is_parent: boolean;
|
||||
parent_task_id: string | null;
|
||||
|
|
@ -620,6 +621,7 @@ export type Database = {
|
|||
assignee_id?: string | null;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
due_date?: string | null;
|
||||
id?: string;
|
||||
is_parent?: boolean;
|
||||
parent_task_id?: string | null;
|
||||
|
|
@ -633,6 +635,7 @@ export type Database = {
|
|||
assignee_id?: string | null;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
due_date?: string | null;
|
||||
id?: string;
|
||||
is_parent?: boolean;
|
||||
parent_task_id?: string | null;
|
||||
|
|
@ -725,6 +728,7 @@ export type Database = {
|
|||
assignee_name: string | null;
|
||||
created_at: string | null;
|
||||
description: string | null;
|
||||
due_date: string | null;
|
||||
id: string | null;
|
||||
is_parent: boolean | null;
|
||||
parent_task_id: string | null;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export type {
|
|||
// ============================================================================
|
||||
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
|
||||
// ============================================================================
|
||||
// Tablo Data Types (Files and Folders)
|
||||
// ============================================================================
|
||||
export type { TabloFolder, TabloFoldersMetadata } from "./tablo-data.types.js";
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
export type {
|
||||
|
|
|
|||
23
packages/shared-types/src/tablo-data.types.ts
Normal file
23
packages/shared-types/src/tablo-data.types.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// ============================================================================
|
||||
// Tablo Data Types (Files and Folders)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Represents a folder within a tablo for organizing files/deliverables
|
||||
*/
|
||||
export interface TabloFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata file structure for storing folder information in S3
|
||||
*/
|
||||
export interface TabloFoldersMetadata {
|
||||
folders: TabloFolder[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
32
supabase/migrations/20260224000000_add_due_date_to_tasks.sql
Normal file
32
supabase/migrations/20260224000000_add_due_date_to_tasks.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- Add due_date column to tasks table for roadmap feature
|
||||
ALTER TABLE "public"."tasks"
|
||||
ADD COLUMN "due_date" date;
|
||||
|
||||
COMMENT ON COLUMN "public"."tasks"."due_date" IS 'Optional due date for tasks and Etapes, used in roadmap views';
|
||||
|
||||
-- Index for roadmap queries (filtering/sorting by due_date within a tablo)
|
||||
CREATE INDEX "tasks_tablo_due_date_idx" ON "public"."tasks" USING btree ("tablo_id", "due_date");
|
||||
|
||||
-- Update tasks_with_assignee view to include due_date
|
||||
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;
|
||||
|
||||
ALTER TABLE "public"."tasks_with_assignee" OWNER TO "postgres";
|
||||
|
||||
COMMENT ON VIEW "public"."tasks_with_assignee" IS 'View that returns tasks with assignee information from profiles';
|
||||
Loading…
Reference in a new issue