commit
14b47db42b
85 changed files with 6743 additions and 725 deletions
|
|
@ -28,12 +28,14 @@ describe("billing helpers", () => {
|
|||
id: "owner-user",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
plan: "annual",
|
||||
},
|
||||
{
|
||||
id: "late-user",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
plan: "solo",
|
||||
},
|
||||
]);
|
||||
|
|
@ -47,18 +49,21 @@ describe("billing helpers", () => {
|
|||
id: "user-1",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
plan: "solo",
|
||||
},
|
||||
{
|
||||
id: "temp-1",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: true,
|
||||
is_client: false,
|
||||
plan: "solo",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
created_at: "2026-01-03T10:00:00.000Z",
|
||||
is_temporary: null,
|
||||
is_client: false,
|
||||
plan: "team",
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ describe("Middleware Tests", () => {
|
|||
const middlewareManager = MiddlewareManager.getInstance();
|
||||
|
||||
const createProfilesSupabaseMock = (result: {
|
||||
data: { is_temporary: boolean } | null;
|
||||
data: { is_temporary?: boolean; is_client?: boolean } | null;
|
||||
error: { message: string } | null;
|
||||
}) => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
|
|
@ -342,6 +342,33 @@ describe("Middleware Tests", () => {
|
|||
expect(res.status).toBe(401);
|
||||
expect(data.error).toBe("User is read only");
|
||||
});
|
||||
|
||||
it("should return 401 for client users", async () => {
|
||||
const app = new Hono();
|
||||
app.use(async (c, next) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
|
||||
(c as any).set(
|
||||
"supabase",
|
||||
createProfilesSupabaseMock({
|
||||
data: { is_temporary: false, is_client: true },
|
||||
error: null,
|
||||
}) as any
|
||||
);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
|
||||
(c as any).set("user", { id: "client-user" } as any);
|
||||
await next();
|
||||
});
|
||||
app.use(middlewareManager.regularUserCheck);
|
||||
app.get("/test", (c) => c.json({ success: true }));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
const client = testClient(app) as any;
|
||||
const res = await client.test.$get();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(data.error).toBe("User is read only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Billing Checkout Access Middleware", () => {
|
||||
|
|
|
|||
386
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal file
386
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import { createClient } from "@supabase/supabase-js";
|
||||
import { testClient } from "hono/testing";
|
||||
import { beforeEach, describe, expect, it, vi } 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";
|
||||
|
||||
// Mock nodemailer
|
||||
const mockSendMail = vi.fn();
|
||||
vi.mock("nodemailer", () => ({
|
||||
default: {
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: mockSendMail,
|
||||
})),
|
||||
},
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: mockSendMail,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Client Invites Endpoints", () => {
|
||||
const config = createConfig();
|
||||
MiddlewareManager.initialize(config);
|
||||
const app = getMainRouter(config);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
const client = testClient(app) as any;
|
||||
|
||||
const ownerUser = getTestUser("owner");
|
||||
const tempUser = getTestUser("temp");
|
||||
|
||||
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
// The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner")
|
||||
const adminTabloId = "test_tablo_owner_private";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const postInvite = (user: TestUserData, tabloId: string, email: string) =>
|
||||
client["client-invites"][":tabloId"].$post(
|
||||
{ param: { tabloId }, json: { email } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
|
||||
const getPending = (user: TestUserData, tabloId: string) =>
|
||||
client["client-invites"][":tabloId"].pending.$get(
|
||||
{ param: { tabloId } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
|
||||
const deleteInvite = (user: TestUserData, tabloId: string, inviteId: number) =>
|
||||
client["client-invites"][":tabloId"][":inviteId"].$delete(
|
||||
{ param: { tabloId, inviteId: String(inviteId) } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
|
||||
const acceptInvite = (user: TestUserData, token: string) =>
|
||||
client["client-invites"].accept[":token"].$post(
|
||||
{ param: { token } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
|
||||
// ─── Helper: insert a client_invite row directly via admin ──────────────────
|
||||
|
||||
const insertClientInvite = async (opts: {
|
||||
tabloId: string;
|
||||
invitedEmail: string;
|
||||
invitedBy: string;
|
||||
token: string;
|
||||
isPending?: boolean;
|
||||
expiresAt?: string;
|
||||
}) => {
|
||||
const expiresAt = opts.expiresAt ?? new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("client_invites")
|
||||
.insert({
|
||||
tablo_id: opts.tabloId,
|
||||
invited_email: opts.invitedEmail,
|
||||
invited_by: opts.invitedBy,
|
||||
invite_token: opts.token,
|
||||
is_pending: opts.isPending ?? true,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to insert client_invite: ${error.message}`);
|
||||
return data.id as number;
|
||||
};
|
||||
|
||||
// ─── Cleanup helper ──────────────────────────────────────────────────────────
|
||||
|
||||
const cleanupInvitesByEmail = async (email: string) => {
|
||||
await supabaseAdmin.from("client_invites").delete().eq("invited_email", email);
|
||||
// Also clean up any client user that may have been created
|
||||
const { data: usersData } = await supabaseAdmin.auth.admin.listUsers();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime
|
||||
const users = usersData as any;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: admin user type
|
||||
const clientUser = users?.users?.find((u: any) => u.email === email);
|
||||
if (clientUser) {
|
||||
await supabaseAdmin.from("tablo_access").delete().eq("user_id", clientUser.id);
|
||||
await supabaseAdmin.auth.admin.deleteUser(clientUser.id);
|
||||
}
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// POST /:tabloId — Create client invite
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("POST /client-invites/:tabloId", () => {
|
||||
const testEmail = "test_client_invite_new@example.com";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupInvitesByEmail(testEmail);
|
||||
});
|
||||
|
||||
it("should create a client invite for a valid email (admin)", async () => {
|
||||
const res = await postInvite(ownerUser, adminTabloId, testEmail);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
// Verify row was inserted
|
||||
const { data: invite } = await supabaseAdmin
|
||||
.from("client_invites")
|
||||
.select("id, invited_email, is_pending")
|
||||
.eq("tablo_id", adminTabloId)
|
||||
.eq("invited_email", testEmail)
|
||||
.single();
|
||||
|
||||
expect(invite).toBeDefined();
|
||||
expect(invite?.is_pending).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject non-admin users with 403", async () => {
|
||||
// tempUser is NOT admin of adminTabloId (owner user owns it)
|
||||
const res = await postInvite(tempUser, adminTabloId, testEmail);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should return 400 for an invalid email", async () => {
|
||||
const res = await postInvite(ownerUser, adminTabloId, "not-an-email");
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain("valid email");
|
||||
});
|
||||
|
||||
it("should return 400 for a missing email", async () => {
|
||||
const res = client["client-invites"][":tabloId"].$post(
|
||||
{ param: { tabloId: adminTabloId }, json: {} },
|
||||
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
|
||||
);
|
||||
expect((await res).status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 401 for unauthenticated requests", async () => {
|
||||
const res = await client["client-invites"][":tabloId"].$post({
|
||||
param: { tabloId: adminTabloId },
|
||||
json: { email: testEmail },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// POST /accept/:token — Accept a client invite
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("POST /client-invites/accept/:token", () => {
|
||||
it("should accept an invite and return tabloId", async () => {
|
||||
const token = `test_accept_valid_${Date.now()}`;
|
||||
|
||||
// Insert invite for the owner user's email
|
||||
await insertClientInvite({
|
||||
tabloId: adminTabloId,
|
||||
invitedEmail: ownerUser.email,
|
||||
invitedBy: ownerUser.userId,
|
||||
token,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await acceptInvite(ownerUser, token);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.tabloId).toBe(adminTabloId);
|
||||
|
||||
// Verify invite is now not pending
|
||||
const { data: invite } = await supabaseAdmin
|
||||
.from("client_invites")
|
||||
.select("is_pending")
|
||||
.eq("invite_token", token)
|
||||
.single();
|
||||
expect(invite?.is_pending).toBe(false);
|
||||
} finally {
|
||||
await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return 410 for an expired invite", async () => {
|
||||
const token = `test_expired_${Date.now()}`;
|
||||
const pastDate = new Date(Date.now() - 1000).toISOString(); // already expired
|
||||
|
||||
await insertClientInvite({
|
||||
tabloId: adminTabloId,
|
||||
invitedEmail: ownerUser.email,
|
||||
invitedBy: ownerUser.userId,
|
||||
token,
|
||||
expiresAt: pastDate,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await acceptInvite(ownerUser, token);
|
||||
expect(res.status).toBe(410);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain("expired");
|
||||
} finally {
|
||||
await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return 403 when email does not match the authenticated user", async () => {
|
||||
const token = `test_email_mismatch_${Date.now()}`;
|
||||
|
||||
// Invite is for tempUser's email but we authenticate as ownerUser
|
||||
await insertClientInvite({
|
||||
tabloId: adminTabloId,
|
||||
invitedEmail: tempUser.email,
|
||||
invitedBy: ownerUser.userId,
|
||||
token,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await acceptInvite(ownerUser, token); // wrong user
|
||||
expect(res.status).toBe(403);
|
||||
} finally {
|
||||
await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return 404 for a non-existent token", async () => {
|
||||
const res = await acceptInvite(ownerUser, "nonexistent_token_xyz");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 401 for unauthenticated requests", async () => {
|
||||
const res = await client["client-invites"].accept[":token"].$post({
|
||||
param: { token: "some_token" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /:tabloId/pending — List pending client invites
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("GET /client-invites/:tabloId/pending", () => {
|
||||
const pendingEmail = "test_client_pending_list@example.com";
|
||||
let insertedId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupInvitesByEmail(pendingEmail);
|
||||
insertedId = await insertClientInvite({
|
||||
tabloId: adminTabloId,
|
||||
invitedEmail: pendingEmail,
|
||||
invitedBy: ownerUser.userId,
|
||||
token: `test_pending_${Date.now()}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return pending invites for an admin", async () => {
|
||||
const res = await getPending(ownerUser, adminTabloId);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(Array.isArray(data.invites)).toBe(true);
|
||||
|
||||
const found = data.invites.find((inv: { id: number }) => inv.id === insertedId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.invited_email).toBe(pendingEmail);
|
||||
expect(found.is_pending).toBe(true);
|
||||
});
|
||||
|
||||
it("should return 403 for a non-admin user", async () => {
|
||||
const res = await getPending(tempUser, adminTabloId);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should return 401 for unauthenticated requests", async () => {
|
||||
const res = await client["client-invites"][":tabloId"].pending.$get({
|
||||
param: { tabloId: adminTabloId },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /:tabloId/:inviteId — Cancel a client invite
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("DELETE /client-invites/:tabloId/:inviteId", () => {
|
||||
const cancelEmail = "test_client_cancel@example.com";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupInvitesByEmail(cancelEmail);
|
||||
});
|
||||
|
||||
it("should cancel a pending invite and revoke client access", async () => {
|
||||
// First create a client user and tablo_access entry via the API
|
||||
const token = `test_cancel_${Date.now()}`;
|
||||
const inviteId = await insertClientInvite({
|
||||
tabloId: adminTabloId,
|
||||
invitedEmail: cancelEmail,
|
||||
invitedBy: ownerUser.userId,
|
||||
token,
|
||||
});
|
||||
|
||||
// Create a mock profile to revoke (uses admin client to simulate client user existing)
|
||||
// We'll skip verifying the user's actual auth account since we just need to test cancellation
|
||||
const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
// Verify invite is now not pending
|
||||
const { data: invite } = await supabaseAdmin
|
||||
.from("client_invites")
|
||||
.select("is_pending")
|
||||
.eq("id", inviteId)
|
||||
.single();
|
||||
expect(invite?.is_pending).toBe(false);
|
||||
});
|
||||
|
||||
it("should return 403 for a non-admin user", async () => {
|
||||
const token = `test_cancel_nonadmin_${Date.now()}`;
|
||||
const inviteId = await insertClientInvite({
|
||||
tabloId: adminTabloId,
|
||||
invitedEmail: cancelEmail,
|
||||
invitedBy: ownerUser.userId,
|
||||
token,
|
||||
});
|
||||
|
||||
const res = await deleteInvite(tempUser, adminTabloId, inviteId);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should return 404 for a non-existent invite", async () => {
|
||||
const res = await deleteInvite(ownerUser, adminTabloId, 999999);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 400 for an already-cancelled invite", async () => {
|
||||
const token = `test_cancel_already_${Date.now()}`;
|
||||
const inviteId = await insertClientInvite({
|
||||
tabloId: adminTabloId,
|
||||
invitedEmail: cancelEmail,
|
||||
invitedBy: ownerUser.userId,
|
||||
token,
|
||||
isPending: false, // already cancelled
|
||||
});
|
||||
|
||||
const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain("pending");
|
||||
});
|
||||
|
||||
it("should return 401 for unauthenticated requests", async () => {
|
||||
const res = await client["client-invites"][":tabloId"][":inviteId"].$delete({
|
||||
param: { tabloId: adminTabloId, inviteId: "1" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ type BillingProfileRow = {
|
|||
id: string;
|
||||
created_at: string | null;
|
||||
is_temporary: boolean | null;
|
||||
is_client: boolean | null;
|
||||
plan: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -87,7 +88,7 @@ export const parseTrialRolloutDate = (
|
|||
export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null;
|
||||
|
||||
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
|
||||
profiles.filter((profile) => profile.is_temporary !== true).length;
|
||||
profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length;
|
||||
|
||||
export const getTrialWindow = (input: {
|
||||
ownerCreatedAt: Date;
|
||||
|
|
@ -179,7 +180,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo
|
|||
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, created_at, is_temporary, plan")
|
||||
.select("id, created_at, is_temporary, is_client, plan")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -363,3 +363,68 @@ export const createInvitedUser = async (
|
|||
|
||||
return { success: true, userId: newUser.user.id };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates or finds a client user, marks them as is_client, and grants tablo access.
|
||||
*/
|
||||
export async function createClientUser(
|
||||
supabase: SupabaseClient,
|
||||
recipientEmail: string,
|
||||
tabloId: string,
|
||||
grantedBy: string
|
||||
): Promise<{ success: boolean; error?: string; userId?: string }> {
|
||||
// Check if user already exists
|
||||
const { data: existingUsersData } = await supabase.auth.admin.listUsers();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime
|
||||
const existingUsers = existingUsersData as any;
|
||||
const existingUser = existingUsers?.users?.find(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: admin user type
|
||||
(u: any) => u.email?.toLowerCase() === recipientEmail.toLowerCase()
|
||||
);
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existingUser) {
|
||||
userId = existingUser.id;
|
||||
// Mark as client if not already
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({ is_client: true })
|
||||
.eq("id", userId)
|
||||
.eq("is_client", false);
|
||||
} else {
|
||||
// Create new auth user (no password — magic link only)
|
||||
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
|
||||
email: recipientEmail,
|
||||
email_confirm: true,
|
||||
user_metadata: { role: "client" },
|
||||
});
|
||||
if (authError || !authData?.user) {
|
||||
return { success: false, error: authError?.message ?? "Failed to create user" };
|
||||
}
|
||||
userId = authData.user.id;
|
||||
await supabase.from("profiles").update({ is_client: true }).eq("id", userId);
|
||||
}
|
||||
|
||||
// Grant tablo access if not already granted
|
||||
const { data: existingAccess } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("id, is_active")
|
||||
.eq("tablo_id", tabloId)
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
|
||||
if (!existingAccess) {
|
||||
await supabase.from("tablo_access").insert({
|
||||
tablo_id: tabloId,
|
||||
user_id: userId,
|
||||
granted_by: grantedBy,
|
||||
is_admin: false,
|
||||
is_active: true,
|
||||
});
|
||||
} else if (!existingAccess.is_active) {
|
||||
await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id);
|
||||
}
|
||||
|
||||
return { success: true, userId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export class MiddlewareManager {
|
|||
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("is_temporary")
|
||||
.select("is_temporary, is_client")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ export class MiddlewareManager {
|
|||
return c.json({ error: error?.message ?? "Profile not found" }, 500);
|
||||
}
|
||||
|
||||
if (!allowTemporaryUsers && profile.is_temporary) {
|
||||
if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) {
|
||||
return c.json({ error: "User is read only" }, 401);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Hono } from "hono";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { MiddlewareManager } from "../middlewares/middleware.js";
|
||||
import { getClientInvitesRouter } from "./clientInvites.js";
|
||||
import { getNotesRouter } from "./notes.js";
|
||||
import { getStripeRouter } from "./stripe.js";
|
||||
import { getTabloRouter } from "./tablo.js";
|
||||
|
|
@ -19,6 +20,7 @@ export const getAuthenticatedRouter = (config: AppConfig) => {
|
|||
authRouter.route("/tablos", getTabloRouter(config));
|
||||
authRouter.route("/tablo-data", getTabloDataRouter());
|
||||
authRouter.route("/notes", getNotesRouter());
|
||||
authRouter.route("/client-invites", getClientInvitesRouter());
|
||||
// stripe routes
|
||||
authRouter.route("/stripe", getStripeRouter(config));
|
||||
|
||||
|
|
|
|||
223
apps/api/src/routers/clientInvites.ts
Normal file
223
apps/api/src/routers/clientInvites.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import { checkTabloAdmin, createClientUser } from "../helpers/helpers.js";
|
||||
import { generateToken } from "../helpers/token.js";
|
||||
import { MiddlewareManager } from "../middlewares/middleware.js";
|
||||
import type { AuthEnv } from "../types/app.types.js";
|
||||
|
||||
const factory = createFactory<AuthEnv>();
|
||||
|
||||
const CLIENT_INVITE_EXPIRY_HOURS = 72;
|
||||
|
||||
/** POST /:tabloId — Create a client invite (admin only) */
|
||||
const createClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const tabloId = c.req.param("tabloId");
|
||||
|
||||
const body = await c.req.json();
|
||||
const rawEmail = String(body.email || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!rawEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail)) {
|
||||
return c.json({ error: "A valid email is required" }, 400);
|
||||
}
|
||||
|
||||
// Create / find the client user and grant tablo access
|
||||
const result = await createClientUser(supabase, rawEmail, tabloId, user.id);
|
||||
if (!result.success || !result.userId) {
|
||||
return c.json({ error: result.error ?? "Failed to create client user" }, 500);
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + CLIENT_INVITE_EXPIRY_HOURS * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
const { error: insertError } = await supabase.from("client_invites").insert({
|
||||
tablo_id: tabloId,
|
||||
invited_email: rawEmail,
|
||||
invited_by: user.id,
|
||||
invite_token: token,
|
||||
is_pending: true,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
if (insertError.code === "23505") {
|
||||
return c.json({ error: "A pending invite already exists for this email and tablo" }, 409);
|
||||
}
|
||||
return c.json({ error: insertError.message }, 500);
|
||||
}
|
||||
|
||||
// Generate a Supabase magic link that redirects to the client portal callback
|
||||
const clientsUrl = process.env.CLIENTS_URL || "https://clients.xtablo.com";
|
||||
const redirectTo = `${clientsUrl}/auth/callback?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const { error: magicLinkError } = await supabase.auth.admin.generateLink({
|
||||
type: "magiclink",
|
||||
email: rawEmail,
|
||||
options: { redirectTo },
|
||||
});
|
||||
|
||||
if (magicLinkError) {
|
||||
console.error("Failed to generate magic link:", magicLinkError);
|
||||
// Non-fatal: invite record is already created
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
/** POST /accept/:token — Accept a client invite */
|
||||
const acceptClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const token = c.req.param("token");
|
||||
|
||||
const { data: invite, error: inviteError } = await supabase
|
||||
.from("client_invites")
|
||||
.select("id, tablo_id, invited_email, invited_by, is_pending, expires_at")
|
||||
.eq("invite_token", token)
|
||||
.maybeSingle();
|
||||
|
||||
if (inviteError) {
|
||||
return c.json({ error: inviteError.message }, 500);
|
||||
}
|
||||
|
||||
if (!invite || !invite.is_pending) {
|
||||
return c.json({ error: "Invite not found or already used" }, 404);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
|
||||
return c.json({ error: "This invite has expired" }, 410);
|
||||
}
|
||||
|
||||
// Email must match the authenticated user
|
||||
if (invite.invited_email?.toLowerCase() !== user.email?.toLowerCase()) {
|
||||
return c.json({ error: "This invite was not issued to your account" }, 403);
|
||||
}
|
||||
|
||||
// Mark invite as accepted
|
||||
await supabase.from("client_invites").update({ is_pending: false }).eq("id", invite.id);
|
||||
|
||||
// Ensure tablo access is active
|
||||
const { data: existingAccess } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("id, is_active")
|
||||
.eq("tablo_id", invite.tablo_id)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!existingAccess) {
|
||||
await supabase.from("tablo_access").insert({
|
||||
tablo_id: invite.tablo_id,
|
||||
user_id: user.id,
|
||||
granted_by: invite.invited_by,
|
||||
is_admin: false,
|
||||
is_active: true,
|
||||
});
|
||||
} else if (!existingAccess.is_active) {
|
||||
await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id);
|
||||
}
|
||||
|
||||
return c.json({ success: true, tabloId: invite.tablo_id });
|
||||
});
|
||||
|
||||
/** GET /:tabloId/pending — List pending client invites (admin only) */
|
||||
const getPendingClientInvites = (
|
||||
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
|
||||
) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const tabloId = c.req.param("tabloId");
|
||||
|
||||
const { data: invites, error } = await supabase
|
||||
.from("client_invites")
|
||||
.select("id, invited_email, expires_at, is_pending, created_at")
|
||||
.eq("tablo_id", tabloId)
|
||||
.eq("is_pending", true)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
return c.json({ invites: invites ?? [] });
|
||||
});
|
||||
|
||||
/** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */
|
||||
const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const inviteId = Number(c.req.param("inviteId"));
|
||||
|
||||
if (!Number.isInteger(inviteId) || inviteId <= 0) {
|
||||
return c.json({ error: "Invalid invite id" }, 400);
|
||||
}
|
||||
|
||||
const { data: invite, error: inviteError } = await supabase
|
||||
.from("client_invites")
|
||||
.select("id, invited_email, is_pending")
|
||||
.eq("id", inviteId)
|
||||
.eq("tablo_id", tabloId)
|
||||
.maybeSingle();
|
||||
|
||||
if (inviteError) {
|
||||
return c.json({ error: inviteError.message }, 500);
|
||||
}
|
||||
|
||||
if (!invite) {
|
||||
return c.json({ error: "Invite not found" }, 404);
|
||||
}
|
||||
|
||||
if (!invite.is_pending) {
|
||||
return c.json({ error: "Invite is no longer pending" }, 400);
|
||||
}
|
||||
|
||||
// Mark invite as cancelled
|
||||
const { error: cancelError } = await supabase
|
||||
.from("client_invites")
|
||||
.update({ is_pending: false })
|
||||
.eq("id", inviteId)
|
||||
.eq("tablo_id", tabloId);
|
||||
|
||||
if (cancelError) {
|
||||
return c.json({ error: cancelError.message }, 500);
|
||||
}
|
||||
|
||||
// Revoke tablo access for the client user
|
||||
if (invite.invited_email) {
|
||||
const { data: clientProfile } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("email", invite.invited_email)
|
||||
.maybeSingle();
|
||||
|
||||
if (clientProfile?.id) {
|
||||
await supabase
|
||||
.from("tablo_access")
|
||||
.update({ is_active: false })
|
||||
.eq("tablo_id", tabloId)
|
||||
.eq("user_id", clientProfile.id);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export const getClientInvitesRouter = () => {
|
||||
const router = new Hono<AuthEnv>();
|
||||
const middlewareManager = MiddlewareManager.getInstance();
|
||||
|
||||
router.post("/:tabloId", ...createClientInvite(middlewareManager));
|
||||
router.post("/accept/:token", ...acceptClientInvite(middlewareManager));
|
||||
router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager));
|
||||
router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager));
|
||||
|
||||
return router;
|
||||
};
|
||||
299
apps/clients/biome.json
Normal file
299
apps/clients/biome.json
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
{
|
||||
"root": false,
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": false,
|
||||
"complexity": {
|
||||
"noAdjacentSpacesInRegex": "error",
|
||||
"noBannedTypes": "error",
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessEscapeInRegex": "error",
|
||||
"noUselessTypeConstraint": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noChildrenProp": "error",
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInvalidBuiltinInstantiation": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedPrivateClassMembers": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error",
|
||||
"useIsNan": "error",
|
||||
"useJsxKeyInIterable": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useValidTypeof": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"nursery": {},
|
||||
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
|
||||
"style": {
|
||||
"noCommonJs": "error",
|
||||
"noNamespace": "error",
|
||||
"useArrayLiterals": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useConst": "error",
|
||||
"useTemplate": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCommentText": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noConstantBinaryExpressions": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateJsxProps": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"noExplicitAny": "error",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noIrregularWhitespace": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noMisleadingInstantiator": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noSparseArray": "error",
|
||||
"noUnsafeDeclarationMerging": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noUselessRegexBackrefs": "error",
|
||||
"noWith": "error",
|
||||
"useGetterReturn": "error",
|
||||
"useNamespaceKeyword": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
},
|
||||
"globals": [
|
||||
"onanimationend",
|
||||
"ongamepadconnected",
|
||||
"onlostpointercapture",
|
||||
"onanimationiteration",
|
||||
"onkeyup",
|
||||
"onmousedown",
|
||||
"onanimationstart",
|
||||
"onslotchange",
|
||||
"onprogress",
|
||||
"ontransitionstart",
|
||||
"onpause",
|
||||
"onended",
|
||||
"onpointerover",
|
||||
"onscrollend",
|
||||
"onformdata",
|
||||
"ontransitionrun",
|
||||
"onanimationcancel",
|
||||
"ondrag",
|
||||
"onchange",
|
||||
"onbeforeinstallprompt",
|
||||
"onbeforexrselect",
|
||||
"onmessage",
|
||||
"ontransitioncancel",
|
||||
"onpointerdown",
|
||||
"onabort",
|
||||
"onpointerout",
|
||||
"oncuechange",
|
||||
"ongotpointercapture",
|
||||
"onscrollsnapchanging",
|
||||
"onsearch",
|
||||
"onsubmit",
|
||||
"onstalled",
|
||||
"onsuspend",
|
||||
"onreset",
|
||||
"onerror",
|
||||
"onresize",
|
||||
"onmouseenter",
|
||||
"ongamepaddisconnected",
|
||||
"ondragover",
|
||||
"onbeforetoggle",
|
||||
"onmouseover",
|
||||
"onpagehide",
|
||||
"onmousemove",
|
||||
"onratechange",
|
||||
"onmessageerror",
|
||||
"onwheel",
|
||||
"ondevicemotion",
|
||||
"onauxclick",
|
||||
"ontransitionend",
|
||||
"onpaste",
|
||||
"onpageswap",
|
||||
"ononline",
|
||||
"ondeviceorientationabsolute",
|
||||
"onkeydown",
|
||||
"onclose",
|
||||
"onselect",
|
||||
"onpageshow",
|
||||
"onpointercancel",
|
||||
"onbeforematch",
|
||||
"onpointerrawupdate",
|
||||
"ondragleave",
|
||||
"onscrollsnapchange",
|
||||
"onseeked",
|
||||
"onwaiting",
|
||||
"onbeforeunload",
|
||||
"onplaying",
|
||||
"onvolumechange",
|
||||
"ondragend",
|
||||
"onstorage",
|
||||
"onloadeddata",
|
||||
"onfocus",
|
||||
"onoffline",
|
||||
"onplay",
|
||||
"onafterprint",
|
||||
"onclick",
|
||||
"oncut",
|
||||
"onmouseout",
|
||||
"ondblclick",
|
||||
"oncanplay",
|
||||
"onloadstart",
|
||||
"onappinstalled",
|
||||
"onpointermove",
|
||||
"ontoggle",
|
||||
"oncontextmenu",
|
||||
"onblur",
|
||||
"oncancel",
|
||||
"onbeforeprint",
|
||||
"oncontextrestored",
|
||||
"onloadedmetadata",
|
||||
"onpointerup",
|
||||
"onlanguagechange",
|
||||
"oncopy",
|
||||
"onselectstart",
|
||||
"onscroll",
|
||||
"onload",
|
||||
"ondragstart",
|
||||
"onbeforeinput",
|
||||
"oncanplaythrough",
|
||||
"oninput",
|
||||
"oninvalid",
|
||||
"ontimeupdate",
|
||||
"ondurationchange",
|
||||
"onselectionchange",
|
||||
"onmouseup",
|
||||
"location",
|
||||
"onkeypress",
|
||||
"onpointerleave",
|
||||
"oncontextlost",
|
||||
"ondrop",
|
||||
"onsecuritypolicyviolation",
|
||||
"oncontentvisibilityautostatechange",
|
||||
"ondeviceorientation",
|
||||
"onseeking",
|
||||
"onrejectionhandled",
|
||||
"onunload",
|
||||
"onmouseleave",
|
||||
"onhashchange",
|
||||
"onpointerenter",
|
||||
"onmousewheel",
|
||||
"onunhandledrejection",
|
||||
"ondragenter",
|
||||
"onpopstate",
|
||||
"onpagereveal",
|
||||
"onemptied"
|
||||
]
|
||||
},
|
||||
"json": {
|
||||
"parser": { "allowComments": true, "allowTrailingCommas": false },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100,
|
||||
"trailingCommas": "none"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
|
||||
{ "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
|
||||
{
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": { "useNodejsImportProtocol": "off" },
|
||||
"suspicious": { "noExplicitAny": "off" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["src/**/*.{ts,tsx}", "*.{ts,tsx}"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": { "noArguments": "error" },
|
||||
"correctness": {
|
||||
"noConstAssign": "off",
|
||||
"noGlobalObjectCalls": "off",
|
||||
"noInvalidBuiltinInstantiation": "off",
|
||||
"noInvalidConstructorSuper": "off",
|
||||
"noSetterReturn": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noUnreachable": "off",
|
||||
"noUnreachableSuper": "off"
|
||||
},
|
||||
"style": { "useConst": "error" },
|
||||
"suspicious": {
|
||||
"noClassAssign": "off",
|
||||
"noDuplicateClassMembers": "off",
|
||||
"noDuplicateObjectKeys": "off",
|
||||
"noDuplicateParameters": "off",
|
||||
"noFunctionAssign": "off",
|
||||
"noImportAssign": "off",
|
||||
"noRedeclare": "off",
|
||||
"noUnsafeNegation": "off",
|
||||
"noVar": "error",
|
||||
"useGetterReturn": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
apps/clients/index.html
Normal file
12
apps/clients/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Xtablo — Client Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="client-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
apps/clients/package.json
Normal file
50
apps/clients/package.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "@xtablo/clients",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5175",
|
||||
"build": "tsc -b && vite build --mode production",
|
||||
"build:staging": "tsc -b && vite build --mode staging",
|
||||
"build:prod": "tsc -b && vite build --mode production",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.5",
|
||||
"@cloudflare/vite-plugin": "^1.9.4",
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.2.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"wrangler": "^4.24.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@xtablo/shared": "workspace:*",
|
||||
"@xtablo/shared-types": "workspace:*",
|
||||
"@xtablo/tablo-views": "workspace:*",
|
||||
"@xtablo/ui": "workspace:*",
|
||||
"@xtablo/chat-ui": "workspace:*",
|
||||
"i18next": "^25.6.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "^16.2.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"zustand": "^5.0.5"
|
||||
}
|
||||
}
|
||||
9
apps/clients/src/App.tsx
Normal file
9
apps/clients/src/App.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import AppRoutes from "./routes";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/clients/src/components/ClientLayout.tsx
Normal file
67
apps/clients/src/components/ClientLayout.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useSession } from "@xtablo/shared/contexts/SessionContext";
|
||||
import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { supabase } from "../lib/supabase";
|
||||
|
||||
function getInitials(email: string): string {
|
||||
const parts = email.split("@")[0].split(/[._-]/);
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function ClientLayout() {
|
||||
const { session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-lg font-medium text-foreground">Accès non autorisé</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Veuillez utiliser le lien reçu dans votre email pour accéder à cette page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const email = session.user.email ?? "";
|
||||
const initials = email ? getInitials(email) : "?";
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Top bar */}
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center justify-between px-4 max-w-7xl mx-auto">
|
||||
{/* Brand */}
|
||||
<span className="text-lg font-bold text-foreground">Xtablo</span>
|
||||
|
||||
{/* User info + logout */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm text-muted-foreground hidden sm:block">{email}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleLogout}>
|
||||
Déconnexion
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/clients/src/i18n.ts
Normal file
31
apps/clients/src/i18n.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import bookingEn from "./locales/en/booking.json";
|
||||
// Import translation files
|
||||
import bookingFr from "./locales/fr/booking.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
fr: {
|
||||
booking: bookingFr,
|
||||
},
|
||||
en: {
|
||||
booking: bookingEn,
|
||||
},
|
||||
},
|
||||
fallbackLng: "fr",
|
||||
defaultNS: "booking",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
caches: ["localStorage"],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
10
apps/clients/src/lib/supabase.ts
Normal file
10
apps/clients/src/lib/supabase.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createSupabaseClient } from "@xtablo/shared";
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error("Missing Supabase environment variables");
|
||||
}
|
||||
|
||||
export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey);
|
||||
3
apps/clients/src/locales/en/booking.json
Normal file
3
apps/clients/src/locales/en/booking.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"welcome": "Welcome"
|
||||
}
|
||||
3
apps/clients/src/locales/fr/booking.json
Normal file
3
apps/clients/src/locales/fr/booking.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"welcome": "Bienvenue"
|
||||
}
|
||||
1266
apps/clients/src/main.css
Normal file
1266
apps/clients/src/main.css
Normal file
File diff suppressed because it is too large
Load diff
29
apps/clients/src/main.tsx
Normal file
29
apps/clients/src/main.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "@xtablo/shared";
|
||||
import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
|
||||
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
|
||||
import { Toaster } from "@xtablo/ui/components/sonner";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { supabase } from "./lib/supabase";
|
||||
|
||||
import "@xtablo/ui/styles/globals.css";
|
||||
import "./main.css";
|
||||
import "./i18n";
|
||||
|
||||
createRoot(document.getElementById("client-root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider supabase={supabase}>
|
||||
<ThemeProvider>
|
||||
<Toaster />
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
66
apps/clients/src/pages/AuthCallback.tsx
Normal file
66
apps/clients/src/pages/AuthCallback.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useSession } from "@xtablo/shared/contexts/SessionContext";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
export function AuthCallback() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
const { session } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasAccepted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !token || hasAccepted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAccepted.current = true;
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_URL as string;
|
||||
|
||||
fetch(`${apiUrl}/api/v1/client-invites/accept/${token}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error((body as { message?: string }).message ?? "Erreur lors de l'acceptation de l'invitation");
|
||||
}
|
||||
return res.json() as Promise<{ tabloId: string }>;
|
||||
})
|
||||
.then((data) => {
|
||||
navigate(`/tablo/${data.tabloId}`, { replace: true });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Accept invite error:", err);
|
||||
setError(
|
||||
"Une erreur est survenue lors de l'acceptation de l'invitation. Veuillez contacter la personne qui vous a invité."
|
||||
);
|
||||
});
|
||||
}, [session, token, navigate]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-3 max-w-md px-4">
|
||||
<p className="text-lg font-medium text-destructive">Erreur</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">Authentification en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
apps/clients/src/pages/ClientTabloListPage.tsx
Normal file
63
apps/clients/src/pages/ClientTabloListPage.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { UserTablo } from "@xtablo/shared-types";
|
||||
import { Navigate, Link } from "react-router-dom";
|
||||
import { supabase } from "../lib/supabase";
|
||||
|
||||
function useClientTablosList() {
|
||||
return useQuery<UserTablo[]>({
|
||||
queryKey: ["client-tablos-list"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.from("user_tablos").select("*");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as UserTablo[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function ClientTabloListPage() {
|
||||
const { data: tablos, isLoading } = useClientTablosList();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tablos || tablos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground">Aucun projet disponible.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tablos.length === 1) {
|
||||
return <Navigate to={`/tablo/${tablos[0].id}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Mes projets</h1>
|
||||
<p className="text-muted-foreground mt-1">Sélectionnez un projet pour y accéder.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tablos.map((tablo) => (
|
||||
<Link
|
||||
key={tablo.id}
|
||||
to={`/tablo/${tablo.id}`}
|
||||
className="block p-5 rounded-lg border border-border bg-card hover:bg-muted/50 transition-colors space-y-2"
|
||||
>
|
||||
{tablo.color && (
|
||||
<div className={`w-8 h-8 rounded-lg ${tablo.color}`} />
|
||||
)}
|
||||
<h2 className="font-semibold text-foreground">{tablo.name}</h2>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
apps/clients/src/pages/ClientTabloPage.tsx
Normal file
310
apps/clients/src/pages/ClientTabloPage.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { buildApi } from "@xtablo/shared";
|
||||
import { useSession } from "@xtablo/shared/contexts/SessionContext";
|
||||
import type { Etape, KanbanTask, TabloFolder, UserTablo } from "@xtablo/shared-types";
|
||||
import { CalendarIcon, FolderIcon, KanbanIcon, ListChecksIcon, MapIcon, MessageCircleIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
EtapesSection,
|
||||
RoadmapSection,
|
||||
TabloDiscussionSection,
|
||||
TabloEventsSection,
|
||||
TabloFilesSection,
|
||||
TabloTasksSection,
|
||||
} from "@xtablo/tablo-views";
|
||||
import { supabase } from "../lib/supabase";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL as string;
|
||||
|
||||
// ─── Local hooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
function useAuthedApi(accessToken: string | undefined) {
|
||||
return buildApi(API_URL).create({
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken ?? ""}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useClientTablo(tabloId: string) {
|
||||
return useQuery<UserTablo>({
|
||||
queryKey: ["client-tablo", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("user_tablos")
|
||||
.select("*")
|
||||
.eq("id", tabloId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data as UserTablo;
|
||||
},
|
||||
enabled: !!tabloId,
|
||||
});
|
||||
}
|
||||
|
||||
function useClientTabloTasks(tabloId: string) {
|
||||
return useQuery<KanbanTask[]>({
|
||||
queryKey: ["client-tasks", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("tasks_with_assignee")
|
||||
.select("*")
|
||||
.eq("tablo_id", tabloId)
|
||||
.eq("is_parent", false)
|
||||
.order("updated_at", { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []) as KanbanTask[];
|
||||
},
|
||||
enabled: !!tabloId,
|
||||
});
|
||||
}
|
||||
|
||||
function useClientTabloEtapes(tabloId: string) {
|
||||
return useQuery<Etape[]>({
|
||||
queryKey: ["client-etapes", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("tasks")
|
||||
.select("*")
|
||||
.eq("tablo_id", tabloId)
|
||||
.eq("is_parent", true)
|
||||
.order("position", { ascending: true });
|
||||
if (error) throw error;
|
||||
return (data ?? []) as Etape[];
|
||||
},
|
||||
enabled: !!tabloId,
|
||||
});
|
||||
}
|
||||
|
||||
function useClientTabloEvents(tabloId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["client-events", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("events_and_tablos")
|
||||
.select("*")
|
||||
.eq("tablo_id", tabloId)
|
||||
.order("start_date", { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
enabled: !!tabloId,
|
||||
});
|
||||
}
|
||||
|
||||
function useClientTabloMembers(tabloId: string, accessToken: string | undefined) {
|
||||
const api = useAuthedApi(accessToken);
|
||||
return useQuery({
|
||||
queryKey: ["client-members", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{
|
||||
members: {
|
||||
id: string;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
}[];
|
||||
}>(`/api/v1/tablos/members/${tabloId}`);
|
||||
return data.members;
|
||||
},
|
||||
enabled: !!tabloId && !!accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
function useClientTabloFiles(tabloId: string, accessToken: string | undefined) {
|
||||
const api = useAuthedApi(accessToken);
|
||||
return useQuery<{ fileNames: string[] }>({
|
||||
queryKey: ["client-tablo-files", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`);
|
||||
return data as { fileNames: string[] };
|
||||
},
|
||||
enabled: !!tabloId && !!accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
function useClientTabloFolders(tabloId: string, accessToken: string | undefined) {
|
||||
const api = useAuthedApi(accessToken);
|
||||
return useQuery<TabloFolder[]>({
|
||||
queryKey: ["client-tablo-folders", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ folders: TabloFolder[] }>(`/api/v1/tablo-folders/${tabloId}`);
|
||||
return data.folders ?? [];
|
||||
},
|
||||
enabled: !!tabloId && !!accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: React.ElementType }[] = [
|
||||
{ id: "overview", label: "Aperçu", icon: ListChecksIcon },
|
||||
{ id: "etapes", label: "Étapes", icon: ListChecksIcon },
|
||||
{ id: "tasks", label: "Tâches", icon: KanbanIcon },
|
||||
{ id: "files", label: "Fichiers", icon: FolderIcon },
|
||||
{ id: "discussion", label: "Discussion", icon: MessageCircleIcon },
|
||||
{ id: "events", label: "Événements", icon: CalendarIcon },
|
||||
{ id: "roadmap", label: "Roadmap", icon: MapIcon },
|
||||
];
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ClientTabloPage() {
|
||||
const { tabloId } = useParams<{ tabloId: string }>();
|
||||
const { session } = useSession();
|
||||
const [activeTab, setActiveTab] = useState<TabId>("overview");
|
||||
|
||||
const accessToken = session?.access_token;
|
||||
const currentUserId = session?.user.id ?? "";
|
||||
|
||||
const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? "");
|
||||
const { data: tasks = [] } = useClientTabloTasks(tabloId ?? "");
|
||||
const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? "");
|
||||
const { data: events, isLoading: eventsLoading, error: eventsError } = useClientTabloEvents(tabloId ?? "");
|
||||
const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken);
|
||||
const { data: filesData, isLoading: filesLoading, error: filesError } = useClientTabloFiles(tabloId ?? "", accessToken);
|
||||
const { data: folders = [], isLoading: foldersLoading, error: foldersError } = useClientTabloFolders(tabloId ?? "", accessToken);
|
||||
|
||||
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
|
||||
|
||||
const currentUser = { id: currentUserId, avatar_url: null };
|
||||
|
||||
if (tabloLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tablo) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground">Projet introuvable.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tablo header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{tablo.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="border-b border-border">
|
||||
<nav className="flex gap-1 overflow-x-auto">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === "overview" && (
|
||||
<div className="space-y-6">
|
||||
{/* Simple overview: list etapes with progress */}
|
||||
<EtapesSection
|
||||
etapes={etapes}
|
||||
tabloTasks={tasks}
|
||||
tabloId={tablo.id}
|
||||
isAdmin={false}
|
||||
onCreateTask={() => {}}
|
||||
onCreateEtape={async () => {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "etapes" && (
|
||||
<EtapesSection
|
||||
etapes={etapes}
|
||||
tabloTasks={tasks}
|
||||
tabloId={tablo.id}
|
||||
isAdmin={false}
|
||||
onCreateTask={() => {}}
|
||||
onCreateEtape={async () => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "tasks" && (
|
||||
<TabloTasksSection
|
||||
tablo={tablo}
|
||||
isAdmin={false}
|
||||
tasks={tasks}
|
||||
members={members}
|
||||
etapes={etapes}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "files" && (
|
||||
<TabloFilesSection
|
||||
tablo={tablo}
|
||||
isAdmin={false}
|
||||
isReadOnly={true}
|
||||
currentUserId={currentUserId}
|
||||
fileNames={fileNames}
|
||||
filesLoading={filesLoading}
|
||||
filesError={filesError instanceof Error ? filesError : null}
|
||||
folders={folders}
|
||||
foldersLoading={foldersLoading}
|
||||
foldersError={foldersError instanceof Error ? foldersError : null}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "discussion" && (
|
||||
<TabloDiscussionSection
|
||||
tablo={tablo}
|
||||
isAdmin={false}
|
||||
currentUserId={currentUserId}
|
||||
members={members}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "events" && (
|
||||
<TabloEventsSection
|
||||
tablo={tablo}
|
||||
isAdmin={false}
|
||||
isReadOnly={true}
|
||||
events={events as Parameters<typeof TabloEventsSection>[0]["events"]}
|
||||
isLoading={eventsLoading}
|
||||
error={eventsError instanceof Error ? eventsError : null}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "roadmap" && (
|
||||
<RoadmapSection
|
||||
tabloTasks={tasks}
|
||||
onDateClick={() => {}}
|
||||
onTaskStatusChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/clients/src/routes.tsx
Normal file
17
apps/clients/src/routes.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Route, Routes } from "react-router-dom";
|
||||
import { ClientLayout } from "./components/ClientLayout";
|
||||
import { AuthCallback } from "./pages/AuthCallback";
|
||||
import { ClientTabloListPage } from "./pages/ClientTabloListPage";
|
||||
import { ClientTabloPage } from "./pages/ClientTabloPage";
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route element={<ClientLayout />}>
|
||||
<Route path="/tablo/:tabloId" element={<ClientTabloPage />} />
|
||||
<Route path="/" element={<ClientTabloListPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
31
apps/clients/tsconfig.json
Normal file
31
apps/clients/tsconfig.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@xtablo/ui": ["../../packages/ui/src"],
|
||||
"@xtablo/ui/*": ["../../packages/ui/src/*"],
|
||||
"@xtablo/shared": ["../../packages/shared/src"],
|
||||
"@xtablo/shared/*": ["../../packages/shared/src/*"],
|
||||
"@xtablo/tablo-views": ["../../packages/tablo-views/src"],
|
||||
"@xtablo/tablo-views/*": ["../../packages/tablo-views/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
18
apps/clients/vite.config.ts
Normal file
18
apps/clients/vite.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, type PluginOption } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const plugins: PluginOption[] = [react(), tailwindcss(), tsconfigPaths({ ignoreConfigErrors: true })];
|
||||
|
||||
if (mode !== "test" && process.env.VITEST !== "true") {
|
||||
plugins.push(cloudflare({ inspectorPort: 9232 }));
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
server: { cors: false },
|
||||
};
|
||||
});
|
||||
9
apps/clients/worker/index.ts
Normal file
9
apps/clients/worker/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default {
|
||||
fetch(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return Response.json({ name: "Cloudflare" });
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
},
|
||||
};
|
||||
16
apps/clients/wrangler.toml
Normal file
16
apps/clients/wrangler.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name = "xtablo-clients"
|
||||
main = "worker/index.ts"
|
||||
compatibility_date = "2025-07-09"
|
||||
|
||||
[assets]
|
||||
directory = "./dist/"
|
||||
not_found_handling = "single-page-application"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[env.staging]
|
||||
route = { pattern = "clients-staging.xtablo.com", custom_domain = true }
|
||||
|
||||
[env.production]
|
||||
route = { pattern = "clients.xtablo.com", custom_domain = true }
|
||||
4
apps/external/vite.config.ts
vendored
4
apps/external/vite.config.ts
vendored
|
|
@ -16,12 +16,12 @@ export default defineConfig(({ mode }) => {
|
|||
react(),
|
||||
// visualizer() as PluginOption,
|
||||
tailwindcss(),
|
||||
tsconfigPaths(),
|
||||
tsconfigPaths({ ignoreConfigErrors: true }),
|
||||
];
|
||||
|
||||
// Only include cloudflare plugin when not in test mode
|
||||
if (mode !== "test" && process.env.VITEST !== "true") {
|
||||
plugins.push(cloudflare());
|
||||
plugins.push(cloudflare({ inspectorPort: 9231 }));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@
|
|||
"@xtablo/chat-ui": "workspace:*",
|
||||
"@xtablo/shared": "workspace:*",
|
||||
"@xtablo/shared-types": "workspace:*",
|
||||
"@xtablo/tablo-views": "workspace:*",
|
||||
"@xtablo/ui": "workspace:*",
|
||||
"ag-grid-community": "^33.2.1",
|
||||
"ag-grid-react": "^33.2.1",
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ export function ChatHeader({
|
|||
<div className="ml-3">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{tablo.name}</h2>
|
||||
{memberCount > 0 && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{memberCount} online
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{memberCount} online</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { cn } from "@xtablo/shared";
|
||||
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
|
||||
import { TaskModal } from "@xtablo/tablo-views";
|
||||
import { CheckCircle2, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -7,7 +8,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -54,11 +54,7 @@ export function Layout() {
|
|||
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<XIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<MenuIcon className="h-6 w-6" />
|
||||
)}
|
||||
{isMobileMenuOpen ? <XIcon className="h-6 w-6" /> : <MenuIcon className="h-6 w-6" />}
|
||||
</Button>
|
||||
|
||||
{/* Mobile backdrop overlay */}
|
||||
|
|
@ -66,9 +62,7 @@ export function Layout() {
|
|||
className={twMerge(
|
||||
"fixed inset-0 z-40 bg-black/50 md:hidden",
|
||||
"transition-opacity duration-300 ease-in-out",
|
||||
isMobileMenuOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none"
|
||||
isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={closeMobileMenu}
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -301,11 +301,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
className={twMerge(
|
||||
"group isolate flex flex-col overflow-y-auto overflow-x-hidden bg-navbar-background transition-all duration-300",
|
||||
"h-full md:h-screen",
|
||||
isMobileMenuOpen
|
||||
? "w-40"
|
||||
: effectivelyCollapsed
|
||||
? "w-16"
|
||||
: "w-48",
|
||||
isMobileMenuOpen ? "w-40" : effectivelyCollapsed ? "w-16" : "w-48",
|
||||
"md:flex",
|
||||
// On mobile in standalone mode, respect safe area insets
|
||||
"pl-[env(safe-area-inset-left,0px)] pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]"
|
||||
|
|
@ -352,7 +348,11 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
"hover:scale-110"
|
||||
)}
|
||||
>
|
||||
{effectivelyCollapsed ? <PlusIcon aria-hidden="true" /> : <MinusIcon aria-hidden="true" />}
|
||||
{effectivelyCollapsed ? (
|
||||
<PlusIcon aria-hidden="true" />
|
||||
) : (
|
||||
<MinusIcon aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ describe("ProtectedRoute", () => {
|
|||
first_name: "Test",
|
||||
last_name: "User",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
last_signed_in: null,
|
||||
plan: "none" as const,
|
||||
created_at: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ vi.mock("../hooks/auth", () => ({
|
|||
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useSubscription } from "../hooks/stripe";
|
||||
|
||||
const mockUseOrganization = vi.mocked(useOrganization);
|
||||
const mockUseSubscription = vi.mocked(useSubscription);
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ const baseUser: User = {
|
|||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
last_signed_in: null,
|
||||
plan: "none",
|
||||
created_at: new Date().toISOString(),
|
||||
|
|
@ -50,7 +52,7 @@ const queryClient = new QueryClient({
|
|||
function renderCard(
|
||||
user: User,
|
||||
orgData: ReturnType<typeof useOrganization>["data"],
|
||||
subscription: ReturnType<typeof useSubscription>["data"] = undefined,
|
||||
subscription: ReturnType<typeof useSubscription>["data"] = undefined
|
||||
) {
|
||||
mockUseOrganization.mockReturnValue({
|
||||
data: orgData,
|
||||
|
|
@ -74,7 +76,14 @@ function renderCard(
|
|||
}
|
||||
|
||||
const baseOrg = {
|
||||
organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null },
|
||||
organization: {
|
||||
id: 1,
|
||||
name: "Org",
|
||||
plan: "none",
|
||||
member_count: 1,
|
||||
tablo_count: 0,
|
||||
logo_url: null,
|
||||
},
|
||||
members: [],
|
||||
invites_sent: [],
|
||||
trial_starts_at: "2026-01-01",
|
||||
|
|
@ -122,9 +131,7 @@ describe("SubscriptionCard", () => {
|
|||
it("shows billing owner restriction when user is not billing owner", () => {
|
||||
const nonOwnerOrg = { ...baseOrg, is_billing_owner: false };
|
||||
renderCard(baseUser, nonOwnerOrg);
|
||||
expect(
|
||||
screen.getByText(/Seul le propriétaire de facturation/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Passer au plan/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { TabloDiscussionSection } from "@xtablo/tablo-views";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { TabloDiscussionSection } from "./TabloDiscussionSection";
|
||||
|
||||
vi.mock("../hooks/useChat", () => ({
|
||||
vi.mock("@xtablo/tablo-views/hooks/useChat", () => ({
|
||||
useChat: () => ({
|
||||
messages: [],
|
||||
sendMessage: vi.fn(),
|
||||
|
|
@ -33,7 +33,7 @@ describe("TabloDiscussionSection", () => {
|
|||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloDiscussionSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloDiscussionSection tablo={mockTablo} isAdmin={true} currentUserId="test-user-id" />
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { TabloEventsSection } from "@xtablo/tablo-views";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { TabloEventsSection } from "./TabloEventsSection";
|
||||
|
||||
vi.mock("../hooks/events", () => ({
|
||||
vi.mock("@xtablo/tablo-views/hooks/events", () => ({
|
||||
useEventsByTablo: () => ({
|
||||
data: [
|
||||
{
|
||||
|
|
@ -46,14 +46,14 @@ describe("TabloEventsSection", () => {
|
|||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays section title", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
|
||||
);
|
||||
// Just check that the component renders
|
||||
expect(container).toBeInTheDocument();
|
||||
|
|
@ -61,7 +61,7 @@ describe("TabloEventsSection", () => {
|
|||
|
||||
it("displays events from the tablo", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
|
||||
);
|
||||
// Component should render the events section
|
||||
expect(container).toBeInTheDocument();
|
||||
|
|
@ -69,7 +69,7 @@ describe("TabloEventsSection", () => {
|
|||
|
||||
it("shows add event button for admin users", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
|
||||
);
|
||||
// Component should render for admin users
|
||||
expect(container).toBeInTheDocument();
|
||||
|
|
@ -77,7 +77,7 @@ describe("TabloEventsSection", () => {
|
|||
|
||||
it("navigates to events page when add button is clicked", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
|
||||
);
|
||||
// Component renders successfully
|
||||
expect(container).toBeInTheDocument();
|
||||
|
|
@ -85,7 +85,7 @@ describe("TabloEventsSection", () => {
|
|||
|
||||
it("shows view all events link", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
|
||||
);
|
||||
// Component renders successfully
|
||||
expect(container).toBeInTheDocument();
|
||||
|
|
@ -93,7 +93,7 @@ describe("TabloEventsSection", () => {
|
|||
|
||||
it("hides add button for non-admin users", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={false} />
|
||||
<TabloEventsSection tablo={mockTablo} isAdmin={false} currentUser={{ id: "test-user-id" }} />
|
||||
);
|
||||
// Component renders for non-admin users
|
||||
expect(container).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { TabloFilesSection } from "@xtablo/tablo-views";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { TabloFilesSection } from "./TabloFilesSection";
|
||||
|
||||
vi.mock("../hooks/files", () => ({
|
||||
useTabloFileNames: () => ({
|
||||
|
|
@ -29,7 +29,12 @@ describe("TabloFilesSection", () => {
|
|||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<TabloFilesSection tablo={mockTablo} isAdmin={true} />
|
||||
<TabloFilesSection
|
||||
tablo={mockTablo}
|
||||
isAdmin={true}
|
||||
currentUserId="test-user-id"
|
||||
currentUser={{ id: "test-user-id" }}
|
||||
/>
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { TabloHeaderActions } from "@xtablo/tablo-views";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
|
||||
|
|
@ -16,7 +17,6 @@ import {
|
|||
} from "../hooks/tasks";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { getEtapeProgressStats } from "../utils/etapeProgress";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
|
||||
interface TabloOverviewSectionProps {
|
||||
tablo: UserTablo;
|
||||
|
|
@ -289,7 +289,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
{t("tablo:overview.description")}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} currentUser={currentUser} />
|
||||
</div>
|
||||
|
||||
{!canManageEtapes && (
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ vi.mock("../hooks/auth", () => ({
|
|||
}));
|
||||
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
|
||||
const mockUseOrganization = vi.mocked(useOrganization);
|
||||
|
||||
const baseUser: User = {
|
||||
|
|
@ -34,6 +35,7 @@ const baseUser: User = {
|
|||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
last_signed_in: null,
|
||||
plan: "none",
|
||||
created_at: new Date().toISOString(),
|
||||
|
|
@ -62,7 +64,14 @@ function renderPanel(user: User, orgData: ReturnType<typeof useOrganization>["da
|
|||
}
|
||||
|
||||
const noPlanOrg = {
|
||||
organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null },
|
||||
organization: {
|
||||
id: 1,
|
||||
name: "Org",
|
||||
plan: "none",
|
||||
member_count: 1,
|
||||
tablo_count: 0,
|
||||
logo_url: null,
|
||||
},
|
||||
members: [],
|
||||
invites_sent: [],
|
||||
trial_starts_at: "2026-01-01",
|
||||
|
|
@ -128,9 +137,7 @@ describe("UpgradePanel", () => {
|
|||
const soloButton = screen.getByText("Passer au plan Solo").closest("button");
|
||||
expect(soloButton).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByText(/Seul le propriétaire de facturation/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when org data is not yet loaded", () => {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ export function UpgradePanel() {
|
|||
disabled={checkoutPending || !isBillingOwner}
|
||||
className="w-full"
|
||||
>
|
||||
Passer au plan Teams ({requiredTeamQuantity} siège{requiredTeamQuantity > 1 ? "s" : ""})
|
||||
Passer au plan Teams ({requiredTeamQuantity} siège
|
||||
{requiredTeamQuantity > 1 ? "s" : ""})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,2 @@
|
|||
export { InlineTaskCreate } from "./InlineTaskCreate";
|
||||
export { KanbanBoard } from "./KanbanBoard";
|
||||
export { KanbanColumn } from "./KanbanColumn";
|
||||
export { KanbanTaskCard } from "./KanbanTaskCard";
|
||||
export { TaskModal } from "./TaskModal";
|
||||
export type { TabloMember } from "./types";
|
||||
export type { TabloMember } from "@xtablo/tablo-views";
|
||||
export { KanbanBoard, TaskModal } from "@xtablo/tablo-views";
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider";
|
||||
import {
|
||||
UpgradeBlockProvider,
|
||||
useMaybeUpgradeBlock,
|
||||
useUpgradeBlock,
|
||||
} from "./UpgradeBlockContext";
|
||||
import { UpgradeBlockProvider, useMaybeUpgradeBlock, useUpgradeBlock } from "./UpgradeBlockContext";
|
||||
|
||||
// Mock the organization hook
|
||||
vi.mock("../hooks/organization", () => ({
|
||||
|
|
@ -14,6 +10,7 @@ vi.mock("../hooks/organization", () => ({
|
|||
}));
|
||||
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
|
||||
const mockUseOrganization = vi.mocked(useOrganization);
|
||||
|
||||
const baseUser: User = {
|
||||
|
|
@ -25,6 +22,7 @@ const baseUser: User = {
|
|||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
last_signed_in: null,
|
||||
plan: "none",
|
||||
created_at: new Date().toISOString(),
|
||||
|
|
@ -72,7 +70,14 @@ function renderWithUser(user: User | null, orgData: ReturnType<typeof useOrganiz
|
|||
}
|
||||
|
||||
const compliantOrgData = {
|
||||
organization: { id: 1, name: "Test Org", plan: "team", member_count: 2, tablo_count: 5, logo_url: null },
|
||||
organization: {
|
||||
id: 1,
|
||||
name: "Test Org",
|
||||
plan: "team",
|
||||
member_count: 2,
|
||||
tablo_count: 5,
|
||||
logo_url: null,
|
||||
},
|
||||
members: [],
|
||||
invites_sent: [],
|
||||
trial_starts_at: "2026-01-01",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { createContext, useContext } from "react";
|
||||
import { getOrganizationUpgradeBlockReason, type UpgradeBlockReason } from "../hooks/stripe";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { getOrganizationUpgradeBlockReason, type UpgradeBlockReason } from "../hooks/stripe";
|
||||
import { useMaybeUser } from "../providers/UserStoreProvider";
|
||||
|
||||
interface UpgradeBlockContextValue {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useState } from "react";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
import { match } from "ts-pattern";
|
||||
import { api } from "../lib/api";
|
||||
import { clearOrgIdCookie } from "./organization";
|
||||
import {
|
||||
DEFAULT_SIGNUP_BILLING_INTENT,
|
||||
PENDING_BILLING_CHECKOUT_PLAN_KEY,
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
SignupBillingIntent,
|
||||
} from "../lib/billing";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { clearOrgIdCookie } from "./organization";
|
||||
|
||||
export type User = SupabaseUser & {
|
||||
user_metadata: {
|
||||
|
|
|
|||
88
apps/main/src/hooks/client_invites.ts
Normal file
88
apps/main/src/hooks/client_invites.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast, useSession } from "@xtablo/shared";
|
||||
import { useAuthedApi } from "./auth";
|
||||
|
||||
type PendingClientInvite = {
|
||||
id: number;
|
||||
invited_email: string;
|
||||
expires_at: string;
|
||||
is_pending: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export const usePendingClientInvites = (tabloId: string) => {
|
||||
const api = useAuthedApi();
|
||||
const { session } = useSession();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["client-invites", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<PendingClientInvite[]>(
|
||||
`/api/v1/client-invites/${tabloId}/pending`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: !!tabloId && !!session,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateClientInvite = () => {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => {
|
||||
const { data } = await api.post<PendingClientInvite>(`/api/v1/client-invites/${tabloId}`, {
|
||||
email,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_data, { tabloId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
|
||||
toast.add(
|
||||
{
|
||||
title: "Lien magique envoyé",
|
||||
description: "L'invitation client a été envoyée avec succès",
|
||||
type: "success",
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error creating client invite:", error);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: "Impossible d'envoyer l'invitation client",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelClientInvite = () => {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => {
|
||||
await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`);
|
||||
},
|
||||
onSuccess: (_data, { tabloId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error cancelling client invite:", error);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: "Impossible d'annuler l'invitation client",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -8,8 +8,8 @@ import type {
|
|||
KanbanTaskUpdate,
|
||||
TaskStatus,
|
||||
} from "@xtablo/shared-types";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useMaybeUpgradeBlock } from "../contexts/UpgradeBlockContext";
|
||||
import { supabase } from "../lib/supabase";
|
||||
|
||||
type CreateEtapeInput = {
|
||||
tabloId: string;
|
||||
|
|
|
|||
|
|
@ -1242,8 +1242,12 @@
|
|||
}
|
||||
|
||||
@keyframes slide {
|
||||
0% { transform: translateX(-100vw); }
|
||||
100% { transform: translateX(100vw); }
|
||||
0% {
|
||||
transform: translateX(-100vw);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100vw);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { ChatMessages, useChat, useChatUnread } from "@xtablo/tablo-views";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ChatChannelPreview } from "../components/ChatChannelPreview";
|
||||
import { ChatHeader } from "../components/ChatHeader";
|
||||
import { ChatMessages } from "../components/ChatMessages";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { useChatUnread } from "../hooks/useChatUnread";
|
||||
import { useTablosList, useTabloMembers } from "../hooks/tablos";
|
||||
import { useTabloMembers, useTablosList } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
export function ChatPage() {
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ import {
|
|||
useInviteOrganizationUser,
|
||||
useOrganization,
|
||||
useRemoveOrganizationMember,
|
||||
useRemoveOrgLogo,
|
||||
useUpdateOrganization,
|
||||
useUploadOrgLogo,
|
||||
useRemoveOrgLogo,
|
||||
} from "../hooks/organization";
|
||||
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile";
|
||||
import { useCookieConsent } from "../hooks/useCookieConsent";
|
||||
|
|
@ -529,7 +529,9 @@ export default function SettingsPage() {
|
|||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={inviteOrganizationUserPending || !inviteEmail.trim() || isAtTeamMemberLimit}
|
||||
disabled={
|
||||
inviteOrganizationUserPending || !inviteEmail.trim() || isAtTeamMemberLimit
|
||||
}
|
||||
onClick={() => {
|
||||
inviteOrganizationUser(inviteEmail.trim());
|
||||
setInviteEmail("");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { cn, toast } from "@xtablo/shared";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, KanbanTask } from "@xtablo/shared-types";
|
||||
import type { KanbanTask } from "@xtablo/shared-types";
|
||||
import {
|
||||
EtapesSection,
|
||||
RoadmapSection,
|
||||
TabloDiscussionSection,
|
||||
TabloEventsSection,
|
||||
TabloFilesSection,
|
||||
TabloTasksSection,
|
||||
TaskModal,
|
||||
useChatUnread,
|
||||
} from "@xtablo/tablo-views";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -14,8 +24,6 @@ import {
|
|||
import { Input } from "@xtablo/ui/components/input";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleCheckIcon,
|
||||
Compass,
|
||||
EllipsisVerticalIcon,
|
||||
|
|
@ -36,19 +44,30 @@ import {
|
|||
Sun,
|
||||
UserPlusIcon,
|
||||
Waves,
|
||||
XIcon,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { GanttChart } from "../components/gantt/GanttChart";
|
||||
import { TaskModal } from "../components/kanban/TaskModal";
|
||||
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
|
||||
import { TabloEventsSection } from "../components/TabloEventsSection";
|
||||
import { TabloFilesSection } from "../components/TabloFilesSection";
|
||||
import { TabloTasksSection } from "../components/TabloTasksSection";
|
||||
import { useChatUnread } from "../hooks/useChatUnread";
|
||||
import {
|
||||
useCancelClientInvite,
|
||||
useCreateClientInvite,
|
||||
usePendingClientInvites,
|
||||
} from "../hooks/client_invites";
|
||||
import { useEventsByTablo } from "../hooks/events";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useTabloFileNames } from "../hooks/tablo_data";
|
||||
import {
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
useTabloFileNames,
|
||||
useUploadTabloFile,
|
||||
} from "../hooks/tablo_data";
|
||||
import {
|
||||
useCreateTabloFolder,
|
||||
useDeleteTabloFolder,
|
||||
useTabloFolders,
|
||||
useUpdateTabloFolder,
|
||||
} from "../hooks/tablo_folders";
|
||||
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import {
|
||||
useTabloMembers,
|
||||
|
|
@ -62,6 +81,7 @@ import {
|
|||
useCreateTask,
|
||||
useTabloEtapes,
|
||||
useUpdateTask,
|
||||
useUpdateTaskPositions,
|
||||
} from "../hooks/tasks";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { getEtapeProgressStats } from "../utils/etapeProgress";
|
||||
|
|
@ -183,6 +203,7 @@ export const TabloDetailsPage = () => {
|
|||
const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [clientInviteEmail, setClientInviteEmail] = useState("");
|
||||
const [isLayoutEditMode, setIsLayoutEditMode] = useState(false);
|
||||
const [draggedOverviewBlock, setDraggedOverviewBlock] = useState<{
|
||||
zone: "left" | "right";
|
||||
|
|
@ -196,8 +217,35 @@ export const TabloDetailsPage = () => {
|
|||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? "");
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
const { data: pendingClientInvites } = usePendingClientInvites(tabloId ?? "");
|
||||
const { mutate: createClientInvite, isPending: isCreatingClientInvite } = useCreateClientInvite();
|
||||
const { mutate: cancelClientInvite, isPending: isCancellingClientInvite } =
|
||||
useCancelClientInvite();
|
||||
const { mutate: updateTask } = useUpdateTask();
|
||||
const { mutate: updateTablo } = useUpdateTablo();
|
||||
const { mutate: updateTablo, mutateAsync: updateTabloAsync } = useUpdateTablo();
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
|
||||
const { mutate: updateTaskPositions } = useUpdateTaskPositions();
|
||||
|
||||
// Files & folders hooks
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: foldersLoading,
|
||||
error: foldersError,
|
||||
} = useTabloFolders(tabloId ?? "");
|
||||
const { mutateAsync: downloadFile } = useDownloadTabloFile();
|
||||
const { mutateAsync: uploadFile } = useUploadTabloFile();
|
||||
const { mutateAsync: deleteFile } = useDeleteTabloFile();
|
||||
const { mutateAsync: createFolder, isPending: isCreatingFolder } = useCreateTabloFolder();
|
||||
const { mutateAsync: updateFolder, isPending: isUpdatingFolder } = useUpdateTabloFolder();
|
||||
const { mutateAsync: deleteFolder } = useDeleteTabloFolder();
|
||||
|
||||
// Events hooks
|
||||
const {
|
||||
data: events,
|
||||
isLoading: eventsLoading,
|
||||
error: eventsError,
|
||||
} = useEventsByTablo(tabloId ?? null);
|
||||
|
||||
const isEmailValid = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
|
@ -500,7 +548,12 @@ export const TabloDetailsPage = () => {
|
|||
</div>
|
||||
|
||||
{/* ── Tab content ─────────────────────────────────────────────────── */}
|
||||
<div className={cn("px-4 sm:px-6 pt-6 pb-8", activeSection === "discussion" && "flex flex-col flex-1 min-h-0 !px-0 !pt-0 !pb-0")}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 sm:px-6 pt-6 pb-8",
|
||||
activeSection === "discussion" && "flex flex-col flex-1 min-h-0 !px-0 !pt-0 !pb-0"
|
||||
)}
|
||||
>
|
||||
{activeSection === "overview" &&
|
||||
(() => {
|
||||
const overviewBlocks: Record<OverviewBlockId, React.ReactNode> = {
|
||||
|
|
@ -783,14 +836,94 @@ export const TabloDetailsPage = () => {
|
|||
);
|
||||
})()}
|
||||
|
||||
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "tasks" && (
|
||||
<TabloTasksSection
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
tasks={tabloTasks}
|
||||
members={members}
|
||||
etapes={etapes}
|
||||
currentUser={currentUser}
|
||||
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onCreateTask={(task) => createTask(task)}
|
||||
onUpdateTask={(task) => updateTask(task)}
|
||||
onUpdateTaskPositions={(updates) => updateTaskPositions(updates)}
|
||||
onUpdateTablo={(data) =>
|
||||
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
|
||||
}
|
||||
onInviteUser={inviteUser}
|
||||
onCancelInvite={(params) =>
|
||||
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "files" && (
|
||||
<TabloFilesSection
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUserId={currentUser.id}
|
||||
fileNames={(filesData?.fileNames ?? []).filter((f) => !f.startsWith("."))}
|
||||
filesLoading={false}
|
||||
filesError={null}
|
||||
folders={foldersData?.folders ?? []}
|
||||
foldersLoading={foldersLoading}
|
||||
foldersError={foldersError as Error | null}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
isCreatingFolder={isCreatingFolder}
|
||||
isUpdatingFolder={isUpdatingFolder}
|
||||
onCreateFile={(params) => uploadFile(params).then(() => undefined)}
|
||||
onDeleteFile={(params) => deleteFile(params).then(() => undefined)}
|
||||
onDownloadFile={(params) => downloadFile(params).then(() => undefined)}
|
||||
onCreateFolder={(params) => createFolder(params).then(() => undefined)}
|
||||
onUpdateFolder={(params) => updateFolder(params).then(() => undefined)}
|
||||
onDeleteFolder={(params) => deleteFolder(params).then(() => undefined)}
|
||||
onUpdateTablo={(data) =>
|
||||
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
|
||||
}
|
||||
onInviteUser={inviteUser}
|
||||
onCancelInvite={(params) =>
|
||||
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "discussion" && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloDiscussionSection
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUserId={currentUser.id}
|
||||
members={members}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "events" && (
|
||||
<TabloEventsSection
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
events={events ?? []}
|
||||
isLoading={eventsLoading}
|
||||
error={eventsError as Error | null}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onCreateEvent={() => undefined}
|
||||
onUpdateTablo={(data) =>
|
||||
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
|
||||
}
|
||||
onInviteUser={inviteUser}
|
||||
onCancelInvite={(params) =>
|
||||
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === "etapes" && (
|
||||
<EtapesSection
|
||||
|
|
@ -798,11 +931,23 @@ export const TabloDetailsPage = () => {
|
|||
tabloTasks={tabloTasks}
|
||||
tabloId={tabloId ?? ""}
|
||||
isAdmin={isAdmin}
|
||||
onCreateTask={(task) =>
|
||||
createTask({
|
||||
...task,
|
||||
status: task.status as "todo" | "in_progress" | "in_review" | "done",
|
||||
})
|
||||
}
|
||||
onCreateEtape={(params) => createEtape(params).then(() => undefined)}
|
||||
isCreatingEtape={isCreatingEtape}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === "roadmap" && (
|
||||
<RoadmapSection etapes={etapes} tabloTasks={tabloTasks} onDateClick={openTaskModal} />
|
||||
<RoadmapSection
|
||||
tabloTasks={tabloTasks}
|
||||
onDateClick={openTaskModal}
|
||||
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -826,82 +971,6 @@ export const TabloDetailsPage = () => {
|
|||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Invite Input */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Email de l'utilisateur"
|
||||
className="flex-1 min-h-[44px]"
|
||||
/>
|
||||
{isInvitingUser ? (
|
||||
<div className="flex justify-center items-center px-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendInvite}
|
||||
disabled={!isEmailValid(inviteEmail)}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Invites */}
|
||||
{pendingInvites && pendingInvites.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-2">
|
||||
Invitations en attente ({pendingInvites.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{pendingInvites.map((invite) => (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="flex items-center space-x-2 p-2 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-xs">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate block">
|
||||
{invite.invited_email}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
cancelInvite({
|
||||
tabloId: tabloId ?? "",
|
||||
inviteId: invite.id,
|
||||
})
|
||||
}
|
||||
disabled={isCancellingInvite || !tabloId}
|
||||
title="Retirer l'invitation"
|
||||
>
|
||||
{isCancellingInvite ? "..." : "Retirer"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members List */}
|
||||
{filteredMembers && filteredMembers.length > 0 && (
|
||||
<div>
|
||||
|
|
@ -938,373 +1007,125 @@ export const TabloDetailsPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border pt-4">
|
||||
{/* Client Access Section */}
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Accès client</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Invitez des clients externes via un lien magique
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Client Invite Input */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={clientInviteEmail}
|
||||
onChange={(e) => setClientInviteEmail(e.target.value)}
|
||||
placeholder="Email du client"
|
||||
className="flex-1 min-h-[44px]"
|
||||
/>
|
||||
{isCreatingClientInvite ? (
|
||||
<div className="flex justify-center items-center px-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (tabloId && clientInviteEmail) {
|
||||
createClientInvite(
|
||||
{ tabloId, email: clientInviteEmail },
|
||||
{ onSuccess: () => setClientInviteEmail("") }
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={!isEmailValid(clientInviteEmail)}
|
||||
>
|
||||
Envoyer le lien
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Client Invites */}
|
||||
{pendingClientInvites && pendingClientInvites.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-2">
|
||||
Invitations client en attente ({pendingClientInvites.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{pendingClientInvites.map((invite) => {
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(new Date(invite.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const isExpiringSoon = daysUntilExpiry < 5;
|
||||
return (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="flex items-center space-x-2 p-2 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-dashed border-blue-200 dark:border-blue-900/50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-400 text-xs flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate block">
|
||||
{invite.invited_email}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
isExpiringSoon
|
||||
? "text-orange-600 dark:text-orange-400 font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isExpiringSoon && "⚠ "}
|
||||
Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{isExpiringSoon && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded font-medium flex-shrink-0">
|
||||
Bientôt expiré
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 flex-shrink-0"
|
||||
onClick={() =>
|
||||
cancelClientInvite({
|
||||
tabloId: tabloId ?? "",
|
||||
inviteId: invite.id,
|
||||
})
|
||||
}
|
||||
disabled={isCancellingClientInvite || !tabloId}
|
||||
title="Annuler l'invitation"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Etapes (Steps) section ─────────────────────────────────────────────────
|
||||
|
||||
function EtapesSection({
|
||||
etapes,
|
||||
tabloTasks,
|
||||
tabloId,
|
||||
isAdmin,
|
||||
}: {
|
||||
etapes: Etape[];
|
||||
tabloTasks: KanbanTask[];
|
||||
tabloId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
|
||||
new Set(etapes.map((e) => e.id))
|
||||
);
|
||||
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
|
||||
const [newEtapeTitle, setNewEtapeTitle] = useState("");
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
|
||||
|
||||
const toggleEtape = (id: string) => {
|
||||
setExpandedEtapes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTask = (etapeId: string) => {
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title || !tabloId) return;
|
||||
createTask({
|
||||
tablo_id: tabloId,
|
||||
title,
|
||||
status: "todo",
|
||||
parent_task_id: etapeId,
|
||||
is_parent: false,
|
||||
position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length,
|
||||
});
|
||||
setNewTaskTitle("");
|
||||
setAddingTaskToEtape(null);
|
||||
};
|
||||
|
||||
const handleAddEtape = async () => {
|
||||
const title = newEtapeTitle.trim();
|
||||
if (!title || !tabloId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
|
||||
|
||||
await createEtape({
|
||||
tabloId,
|
||||
title,
|
||||
position: nextPosition,
|
||||
});
|
||||
|
||||
setNewEtapeTitle("");
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
todo: {
|
||||
label: "À faire",
|
||||
color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
|
||||
},
|
||||
in_progress: {
|
||||
label: "En cours",
|
||||
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
},
|
||||
in_review: {
|
||||
label: "Vérification",
|
||||
color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
},
|
||||
done: {
|
||||
label: "Terminé",
|
||||
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isAdmin && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Input
|
||||
value={newEtapeTitle}
|
||||
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
||||
placeholder="Nom de la nouvelle étape..."
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleAddEtape();
|
||||
}
|
||||
}}
|
||||
className="h-11 sm:h-9 sm:w-80"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleAddEtape()}
|
||||
disabled={isCreatingEtape || !newEtapeTitle.trim()}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une étape
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etapes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucune étape</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
Les étapes permettent de structurer votre projet en grandes phases
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
etapes.map((etape, index) => {
|
||||
const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id);
|
||||
const doneCount = childTasks.filter((t) => t.status === "done").length;
|
||||
const totalCount = childTasks.length;
|
||||
const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
|
||||
const isExpanded = expandedEtapes.has(etape.id);
|
||||
|
||||
// Derive status from child tasks instead of etape.status
|
||||
const derivedStatus =
|
||||
totalCount === 0
|
||||
? "todo"
|
||||
: doneCount === totalCount
|
||||
? "done"
|
||||
: doneCount > 0
|
||||
? "in_progress"
|
||||
: "todo";
|
||||
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={etape.id}
|
||||
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Etape header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEtape(etape.id)}
|
||||
className="w-full flex items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left min-h-[56px]"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
|
||||
{etape.title}
|
||||
</h3>
|
||||
{etape.description && (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
|
||||
{etape.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{etape.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"items-center gap-1 text-xs hidden sm:flex",
|
||||
derivedStatus !== "done" &&
|
||||
new Date(etape.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(etape.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-xs font-medium",
|
||||
status.color
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Child tasks + add task */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{childTasks.length > 0 && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{childTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(task.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
(statusConfig[task.status] ?? statusConfig.todo).color
|
||||
)}
|
||||
>
|
||||
{(statusConfig[task.status] ?? statusConfig.todo).label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
|
||||
<div className="px-3 sm:px-5 py-4 pl-8 sm:pl-16 text-sm text-muted-foreground">
|
||||
Aucune tâche dans cette étape
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add task */}
|
||||
{addingTaskToEtape === etape.id ? (
|
||||
<div className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddTask(etape.id);
|
||||
if (e.key === "Escape") {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder="Nom de la tâche..."
|
||||
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTask(etape.id)}
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="text-xs font-medium px-3 py-2 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors min-h-[36px] shrink-0"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-2 min-h-[36px] shrink-0"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingTaskToEtape(etape.id);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700 min-h-[44px]"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une tâche
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Roadmap Section ─────────────────────────────────────────────────────────
|
||||
|
||||
function RoadmapSection({
|
||||
tabloTasks,
|
||||
onDateClick,
|
||||
}: {
|
||||
etapes: Etape[];
|
||||
tabloTasks: KanbanTask[];
|
||||
onDateClick: (date: Date) => void;
|
||||
}) {
|
||||
const { mutate: updateTask } = useUpdateTask();
|
||||
|
||||
return (
|
||||
<GanttChart
|
||||
tasks={tabloTasks}
|
||||
isLoading={false}
|
||||
onDateClick={onDateClick}
|
||||
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { DeleteTabloModal } from "@ui/components/DeleteTabloModal";
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { toast } from "@xtablo/shared";
|
||||
import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { TaskModal } from "@xtablo/tablo-views";
|
||||
import { Badge } from "@xtablo/ui/components/badge";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -40,11 +42,9 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
|||
import { DashboardActionCards } from "src/components/DashboardActionCards";
|
||||
import { DashboardTaskList } from "src/components/DashboardTaskList";
|
||||
import { InviteOrganizationModal } from "src/components/InviteOrganizationModal";
|
||||
import { TaskModal } from "src/components/kanban/TaskModal";
|
||||
import { ProjectCardList } from "src/components/ProjectCardList";
|
||||
import { Badge } from "@xtablo/ui/components/badge";
|
||||
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
|
|
@ -107,7 +107,7 @@ export const TabloPage = () => {
|
|||
|
||||
const user = useUser();
|
||||
const { data: organizationData } = useOrganization();
|
||||
const isReadOnly = isReadOnlyUser || !canCreateTablo;
|
||||
const isReadOnly = isReadOnlyUser || canCreateTablo === false;
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types";
|
||||
import { GanttChart, TaskModal } from "@xtablo/tablo-views";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -40,8 +41,6 @@ import { useMemo, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GanttChart } from "../components/gantt/GanttChart";
|
||||
import { TaskModal } from "../components/kanban/TaskModal";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ describe("TestUserStoreProvider", () => {
|
|||
email: null,
|
||||
first_name: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
last_name: null,
|
||||
short_user_id: "short-id",
|
||||
last_signed_in: null,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const defaultUser = {
|
|||
email: "john@example.com",
|
||||
avatar_url: "https://example.com/avatar.jpg",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
last_signed_in: null,
|
||||
plan: "none" as const,
|
||||
created_at: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => {
|
|||
react(),
|
||||
visualizer() as PluginOption,
|
||||
tailwindcss(),
|
||||
tsconfigPaths(),
|
||||
tsconfigPaths({ ignoreConfigErrors: true }),
|
||||
];
|
||||
|
||||
plugins.push(
|
||||
|
|
|
|||
1822
docs/superpowers/plans/2026-04-15-client-magic-links.md
Normal file
1822
docs/superpowers/plans/2026-04-15-client-magic-links.md
Normal file
File diff suppressed because it is too large
Load diff
223
docs/superpowers/specs/2026-04-15-client-magic-links-design.md
Normal file
223
docs/superpowers/specs/2026-04-15-client-magic-links-design.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# Client Magic Links — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the temporary user invitation model with a magic link system for external client access. Clients access tablos via a dedicated portal at `clients.xtablo.com` (`apps/clients`), authenticated through Supabase passwordless magic links. Tablo view components are extracted into a shared `packages/tablo-views` package consumed by both `apps/main` and `apps/clients`.
|
||||
|
||||
Temporary users remain untouched during the transition period.
|
||||
|
||||
## Data Model
|
||||
|
||||
### New column: `profiles.is_client`
|
||||
|
||||
- `is_client: boolean NOT NULL DEFAULT false`
|
||||
- Marks users created via client magic link invites
|
||||
- Distinct from `is_temporary` — clean separation for the transition
|
||||
- Excluded from billing (`getBillableMemberCount` filters out `is_client` users)
|
||||
|
||||
### New table: `client_invites`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | serial PK | |
|
||||
| `tablo_id` | text FK -> tablos | |
|
||||
| `invited_email` | varchar(255) | |
|
||||
| `invited_by` | uuid FK -> profiles | |
|
||||
| `invite_token` | text | URL-safe token for the magic link |
|
||||
| `expires_at` | timestamptz | Default: `now() + interval '30 days'` |
|
||||
| `is_pending` | boolean DEFAULT true | Flipped to false on acceptance |
|
||||
| `created_at` | timestamptz DEFAULT now() | |
|
||||
|
||||
RLS policies:
|
||||
- Admins (invite senders) can read/manage their invites
|
||||
- Client users can read their own invites by email match
|
||||
|
||||
### Existing table: `tablo_access`
|
||||
|
||||
No schema changes. Client users get a standard row with `is_admin: false`, `is_active: true`. Access revocation uses the existing `is_active = false` pattern.
|
||||
|
||||
## Magic Link Invitation Flow
|
||||
|
||||
### Sending an invite (admin in `apps/main`)
|
||||
|
||||
1. Admin opens tablo share dialog, enters client email
|
||||
2. `POST /api/v1/tablos/:tabloId/client-invites` — validates admin access, creates `client_invites` row with generated token and `expires_at = now() + 30 days`
|
||||
3. If no Supabase account exists for that email, the API creates one via `supabase.auth.admin.createUser({ email })` and sets `is_client: true` on the resulting profile row. A `tablo_access` row is pre-granted (`is_admin: false`, `is_active: true`).
|
||||
4. API calls `supabase.auth.admin.generateLink({ type: 'magiclink', email, options: { redirectTo: 'https://clients.xtablo.com/auth/callback?token=<invite_token>' } })` to generate the magic link
|
||||
5. Supabase sends the magic link email to the client
|
||||
|
||||
### Client clicks the link
|
||||
|
||||
1. Supabase verifies the auth token, redirects to `clients.xtablo.com/auth/callback?token=<invite_token>`
|
||||
2. Callback page exchanges the Supabase auth token for a session
|
||||
3. The `invite_token` is used to call `POST /api/v1/client-invites/:token/accept` — marks invite as accepted (`is_pending: false`), confirms `tablo_access` is active
|
||||
4. Client is redirected to `clients.xtablo.com/tablo/:tabloId`
|
||||
|
||||
### Expiration and renewal
|
||||
|
||||
- Expired invites (past `expires_at`) are rejected at acceptance time with a clear error message
|
||||
- Admins can re-invite the same email, creating a new `client_invites` row with a fresh 30-day window
|
||||
- Admins are warned in the UI when the expiration is soon (less than 5 days)
|
||||
- Admin can revoke access by setting `tablo_access.is_active = false`
|
||||
|
||||
### Returning clients
|
||||
|
||||
- Active session + valid `tablo_access` = direct access, no re-invitation needed
|
||||
- Expired session requires a new magic link from the admin
|
||||
|
||||
## API Permission Scoping
|
||||
|
||||
### Middleware
|
||||
|
||||
New middleware variant: `clientUserCheckMiddleware` — returns `403` for `is_client` users on non-client-accessible routes.
|
||||
|
||||
### Client-accessible endpoints
|
||||
|
||||
- `GET /api/v1/tablos/:tabloId` — view tablo details
|
||||
- `GET /api/v1/tablo-data/:tabloId/*` — tasks, etapes, events, files metadata
|
||||
- `GET /api/v1/tablo-files/:tabloId/*` — file downloads
|
||||
- `POST /api/v1/tablo-files/:tabloId/upload` — file uploads
|
||||
- Chat endpoints (messages, typing, presence via WebSocket)
|
||||
- `GET /api/v1/user/me` — own profile
|
||||
|
||||
### Blocked for client users
|
||||
|
||||
- Tablo CRUD (create, update, delete)
|
||||
- Invite management (sending/cancelling invites)
|
||||
- Organization endpoints
|
||||
- Billing/Stripe endpoints
|
||||
- Settings, user management
|
||||
|
||||
### Billing
|
||||
|
||||
`getBillableMemberCount` updated to exclude `is_client` users (same pattern as `is_temporary`).
|
||||
|
||||
### RLS policies
|
||||
|
||||
New row-level policies on `client_invites`:
|
||||
- Admins can manage invites they created
|
||||
- Clients can read their own invites (by email match)
|
||||
|
||||
## `packages/tablo-views` — Shared Package
|
||||
|
||||
Source-only package (TypeScript directly, no build step). Same pattern as `@xtablo/shared` and `@xtablo/ui`.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
packages/tablo-views/
|
||||
├── package.json (@xtablo/tablo-views)
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── TabloOverviewSection.tsx
|
||||
├── TabloEtapesSection.tsx
|
||||
├── TabloTasksSection.tsx
|
||||
├── TabloFilesSection.tsx
|
||||
├── TabloDiscussionSection.tsx
|
||||
├── TabloEventsSection.tsx
|
||||
├── TabloRoadmapSection.tsx
|
||||
├── components/ (shared sub-components these sections depend on)
|
||||
└── hooks/ (data-fetching hooks for tablo views, including useChat)
|
||||
```
|
||||
|
||||
### What moves from `apps/main`
|
||||
|
||||
- The 7 tab section components
|
||||
- Sub-components they directly depend on (task cards, file list items, gantt chart, etc.)
|
||||
- Data-fetching hooks used exclusively by these views (including `useChat` from `apps/main/src/hooks/useChat.ts`)
|
||||
|
||||
### What stays in `apps/main`
|
||||
|
||||
- `TabloDetailsPage` (page shell with tab navigation, share dialog, invite management)
|
||||
- Layout, navigation, routing
|
||||
- App-level providers
|
||||
|
||||
### Dependencies
|
||||
|
||||
`@xtablo/tablo-views` depends on:
|
||||
- `@xtablo/ui`
|
||||
- `@xtablo/shared`
|
||||
- `@xtablo/shared-types`
|
||||
- `@xtablo/chat-ui`
|
||||
|
||||
Consumed by both `apps/main` and `apps/clients`.
|
||||
|
||||
### Refactor in `apps/main`
|
||||
|
||||
`TabloDetailsPage` imports sections from `@xtablo/tablo-views` instead of local files. Behavior stays identical — this is a move, not a rewrite.
|
||||
|
||||
## `apps/clients` — Client Portal App
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
apps/clients/
|
||||
├── package.json (@xtablo/clients)
|
||||
├── vite.config.ts
|
||||
├── wrangler.toml (clients.xtablo.com)
|
||||
├── worker/index.ts
|
||||
├── index.html
|
||||
├── tsconfig.json
|
||||
├── tsconfig.app.json
|
||||
└── src/
|
||||
├── main.tsx
|
||||
├── App.tsx
|
||||
├── routes.tsx
|
||||
├── pages/
|
||||
│ ├── AuthCallback.tsx
|
||||
│ └── ClientTabloPage.tsx
|
||||
└── components/
|
||||
└── ClientLayout.tsx
|
||||
```
|
||||
|
||||
### Cloudflare Worker
|
||||
|
||||
`wrangler.toml` routes `clients.xtablo.com` with SPA not-found handling. Same asset-serving pattern as `apps/main` and `apps/external`.
|
||||
|
||||
### Layout
|
||||
|
||||
`ClientLayout.tsx` — no sidebar. Minimal top bar with:
|
||||
- Tablo name and color
|
||||
- Client user avatar and name
|
||||
- Logout action
|
||||
|
||||
### Routes
|
||||
|
||||
| Path | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| `/auth/callback` | `AuthCallback` | Supabase magic link redirect + invite token acceptance |
|
||||
| `/tablo/:tabloId` | `ClientTabloPage` | Scoped tablo view with all tabs |
|
||||
| `/` | Redirect | To `/tablo/:tabloId` if one tablo, or simple list if multiple |
|
||||
|
||||
### `ClientTabloPage`
|
||||
|
||||
Renders the same tab system as `TabloDetailsPage` using components from `@xtablo/tablo-views`. Differences from `apps/main`:
|
||||
- No share/invite dialog
|
||||
- No tablo settings or delete actions
|
||||
- No admin-only actions in the UI
|
||||
- File section: download and upload enabled, no delete
|
||||
|
||||
### Providers
|
||||
|
||||
`QueryClientProvider`, `SessionProvider`, `ThemeProvider`, i18n — same setup as other apps. No `UserStoreProvider` or organization context (clients don't belong to orgs).
|
||||
|
||||
### Dev server
|
||||
|
||||
Port 5175 via `pnpm dev:clients`.
|
||||
|
||||
## Chat Integration
|
||||
|
||||
Client users get real Supabase accounts, so chat works with minimal changes:
|
||||
|
||||
- **Authentication:** Same JWT-based auth for WebSocket connections
|
||||
- **Identity:** Profile row (name, optional avatar) used for chat display. Profile seeded with invited email on creation. Client can update display name on first access.
|
||||
- **Permissions:** Client users can send messages and see typing indicators in tablo discussions they have access to. Tablo ID maps to channel ID.
|
||||
- **`@xtablo/chat-ui`:** No changes needed. Components are already app-agnostic.
|
||||
- **`useChat` hook:** Moves to `packages/tablo-views/src/hooks/` so both apps can use it.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
- Temporary users (`is_temporary`) remain untouched
|
||||
- Existing tablo invitations continue to work via `apps/main`
|
||||
- New client invites use the magic link flow via `apps/clients`
|
||||
- Once all clients have migrated to magic links, a future phase removes `is_temporary` and related code
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
"dev": "turbo dev",
|
||||
"dev:main": "turbo dev --filter=@xtablo/main",
|
||||
"dev:external": "turbo dev --filter=@xtablo/external",
|
||||
"dev:clients": "turbo dev --filter=@xtablo/clients",
|
||||
"dev:api": "turbo dev --filter=@xtablo/api",
|
||||
"deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main",
|
||||
"deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,54 @@ export type Database = {
|
|||
},
|
||||
];
|
||||
};
|
||||
client_invites: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
id: number;
|
||||
invited_by: string;
|
||||
invited_email: string;
|
||||
invite_token: string;
|
||||
is_pending: boolean;
|
||||
tablo_id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
expires_at?: string;
|
||||
id?: number;
|
||||
invited_by: string;
|
||||
invited_email: string;
|
||||
invite_token: string;
|
||||
is_pending?: boolean;
|
||||
tablo_id: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
expires_at?: string;
|
||||
id?: number;
|
||||
invited_by?: string;
|
||||
invited_email?: string;
|
||||
invite_token?: string;
|
||||
is_pending?: boolean;
|
||||
tablo_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "client_invites_tablo_id_fkey";
|
||||
columns: ["tablo_id"];
|
||||
isOneToOne: false;
|
||||
referencedRelation: "tablos";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "client_invites_invited_by_fkey";
|
||||
columns: ["invited_by"];
|
||||
isOneToOne: false;
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
devis: {
|
||||
Row: {
|
||||
client_email: string;
|
||||
|
|
@ -385,6 +433,7 @@ export type Database = {
|
|||
email: string | null;
|
||||
first_name: string | null;
|
||||
id: string;
|
||||
is_client: boolean;
|
||||
is_temporary: boolean;
|
||||
last_name: string | null;
|
||||
last_signed_in: string | null;
|
||||
|
|
@ -398,6 +447,7 @@ export type Database = {
|
|||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
id: string;
|
||||
is_client?: boolean;
|
||||
is_temporary?: boolean;
|
||||
last_name?: string | null;
|
||||
last_signed_in?: string | null;
|
||||
|
|
@ -411,6 +461,7 @@ export type Database = {
|
|||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
id?: string;
|
||||
is_client?: boolean;
|
||||
is_temporary?: boolean;
|
||||
last_name?: string | null;
|
||||
last_signed_in?: string | null;
|
||||
|
|
|
|||
41
packages/tablo-views/package.json
Normal file
41
packages/tablo-views/package.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "@xtablo/tablo-views",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./*": "./src/*.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@xtablo/chat-ui": "workspace:*",
|
||||
"@xtablo/shared": "workspace:*",
|
||||
"@xtablo/shared-types": "workspace:*",
|
||||
"@xtablo/ui": "workspace:*",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "^16.2.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"tailwind-merge": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.5",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.2.2"
|
||||
}
|
||||
}
|
||||
37
packages/tablo-views/src/ClickOutside.tsx
Normal file
37
packages/tablo-views/src/ClickOutside.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useClickOutside } from "@xtablo/shared/hooks/useClickOutside";
|
||||
import React from "react";
|
||||
|
||||
interface ClickOutsideProps {
|
||||
children: React.ReactNode;
|
||||
onClickOutside: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that wraps children and detects clicks outside
|
||||
* @param children - The content to wrap
|
||||
* @param onClickOutside - Function to call when clicking outside
|
||||
* @param className - Optional className for the wrapper
|
||||
* @param disabled - Disable click outside detection
|
||||
*/
|
||||
export const ClickOutside: React.FC<ClickOutsideProps> = ({
|
||||
children,
|
||||
onClickOutside,
|
||||
className,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const ref = useClickOutside<HTMLDivElement>(
|
||||
disabled
|
||||
? () => {
|
||||
// Do nothing
|
||||
}
|
||||
: onClickOutside
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
366
packages/tablo-views/src/EtapesSection.tsx
Normal file
366
packages/tablo-views/src/EtapesSection.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { cn } from "@xtablo/shared";
|
||||
import type { Etape, KanbanTask } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleCheckIcon,
|
||||
ListChecksIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface EtapesSectionProps {
|
||||
etapes: Etape[];
|
||||
tabloTasks: KanbanTask[];
|
||||
tabloId: string;
|
||||
isAdmin: boolean;
|
||||
onCreateTask: (task: {
|
||||
tablo_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
parent_task_id: string;
|
||||
is_parent: boolean;
|
||||
position: number;
|
||||
}) => void;
|
||||
onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise<void>;
|
||||
isCreatingEtape?: boolean;
|
||||
}
|
||||
|
||||
export function EtapesSection({
|
||||
etapes,
|
||||
tabloTasks,
|
||||
tabloId,
|
||||
isAdmin,
|
||||
onCreateTask,
|
||||
onCreateEtape,
|
||||
isCreatingEtape = false,
|
||||
}: EtapesSectionProps) {
|
||||
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
|
||||
new Set(etapes.map((e) => e.id))
|
||||
);
|
||||
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
|
||||
const [newEtapeTitle, setNewEtapeTitle] = useState("");
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
|
||||
const toggleEtape = (id: string) => {
|
||||
setExpandedEtapes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTask = (etapeId: string) => {
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title || !tabloId) return;
|
||||
onCreateTask({
|
||||
tablo_id: tabloId,
|
||||
title,
|
||||
status: "todo",
|
||||
parent_task_id: etapeId,
|
||||
is_parent: false,
|
||||
position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length,
|
||||
});
|
||||
setNewTaskTitle("");
|
||||
setAddingTaskToEtape(null);
|
||||
};
|
||||
|
||||
const handleAddEtape = async () => {
|
||||
const title = newEtapeTitle.trim();
|
||||
if (!title || !tabloId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
|
||||
|
||||
await onCreateEtape({
|
||||
tabloId,
|
||||
title,
|
||||
position: nextPosition,
|
||||
});
|
||||
|
||||
setNewEtapeTitle("");
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
todo: {
|
||||
label: "À faire",
|
||||
color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
|
||||
},
|
||||
in_progress: {
|
||||
label: "En cours",
|
||||
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
},
|
||||
in_review: {
|
||||
label: "Vérification",
|
||||
color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
},
|
||||
done: {
|
||||
label: "Terminé",
|
||||
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isAdmin && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Input
|
||||
value={newEtapeTitle}
|
||||
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
||||
placeholder="Nom de la nouvelle étape..."
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleAddEtape();
|
||||
}
|
||||
}}
|
||||
className="h-11 sm:h-9 sm:w-80"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleAddEtape()}
|
||||
disabled={isCreatingEtape || !newEtapeTitle.trim()}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une étape
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etapes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucune étape</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
Les étapes permettent de structurer votre projet en grandes phases
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
etapes.map((etape, index) => {
|
||||
const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id);
|
||||
const doneCount = childTasks.filter((t) => t.status === "done").length;
|
||||
const totalCount = childTasks.length;
|
||||
const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
|
||||
const isExpanded = expandedEtapes.has(etape.id);
|
||||
|
||||
// Derive status from child tasks instead of etape.status
|
||||
const derivedStatus =
|
||||
totalCount === 0
|
||||
? "todo"
|
||||
: doneCount === totalCount
|
||||
? "done"
|
||||
: doneCount > 0
|
||||
? "in_progress"
|
||||
: "todo";
|
||||
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={etape.id}
|
||||
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Etape header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEtape(etape.id)}
|
||||
className="w-full flex items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left min-h-[56px]"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
|
||||
{etape.title}
|
||||
</h3>
|
||||
{etape.description && (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
|
||||
{etape.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{etape.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"items-center gap-1 text-xs hidden sm:flex",
|
||||
derivedStatus !== "done" &&
|
||||
new Date(etape.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(etape.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-xs font-medium",
|
||||
status.color
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Child tasks + add task */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{childTasks.length > 0 && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{childTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(task.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
(statusConfig[task.status] ?? statusConfig.todo).color
|
||||
)}
|
||||
>
|
||||
{(statusConfig[task.status] ?? statusConfig.todo).label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
|
||||
<div className="px-3 sm:px-5 py-4 pl-8 sm:pl-16 text-sm text-muted-foreground">
|
||||
Aucune tâche dans cette étape
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add task */}
|
||||
{addingTaskToEtape === etape.id ? (
|
||||
<div className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddTask(etape.id);
|
||||
if (e.key === "Escape") {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder="Nom de la tâche..."
|
||||
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTask(etape.id)}
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="text-xs font-medium px-3 py-2 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors min-h-[36px] shrink-0"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-2 min-h-[36px] shrink-0"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingTaskToEtape(etape.id);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700 min-h-[44px]"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une tâche
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
packages/tablo-views/src/ImageColorPicker.tsx
Normal file
114
packages/tablo-views/src/ImageColorPicker.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
interface ImageColorPickerProps {
|
||||
creationMode: "image" | "color";
|
||||
setCreationMode: (mode: "image" | "color") => void;
|
||||
selectedColor: string;
|
||||
setSelectedColor: (color: string) => void;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
"bg-blue-500",
|
||||
"bg-green-500",
|
||||
"bg-purple-500",
|
||||
"bg-red-500",
|
||||
"bg-yellow-500",
|
||||
"bg-indigo-500",
|
||||
"bg-pink-500",
|
||||
"bg-teal-500",
|
||||
"bg-orange-500",
|
||||
"bg-cyan-500",
|
||||
];
|
||||
|
||||
export const ImageColorPicker = ({
|
||||
creationMode,
|
||||
setCreationMode,
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
}: ImageColorPickerProps) => {
|
||||
return (
|
||||
<div className="my-4 space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-800 dark:text-gray-300 mb-2">
|
||||
Style
|
||||
</label>
|
||||
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
||||
creationMode === "image"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
} transition-colors`}
|
||||
onClick={() => setCreationMode("image")}
|
||||
>
|
||||
Image (Bientôt disponible)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
||||
creationMode === "color"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
} transition-colors`}
|
||||
onClick={() => setCreationMode("color")}
|
||||
>
|
||||
Couleur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Mode */}
|
||||
{creationMode === "image" && (
|
||||
<div className="space-y-4">
|
||||
{/* File Upload - Coming Soon */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 border border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="mx-auto h-8 w-8 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
<span className="font-medium">Import d'images</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Bientôt disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Mode */}
|
||||
{creationMode === "color" && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Couleur
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`w-12 h-12 ${color} rounded-lg border-2 ${
|
||||
selectedColor === color
|
||||
? "border-gray-800 dark:border-white scale-110"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
} hover:scale-105 transition-all duration-200`}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
>
|
||||
{selectedColor === color && <span className="text-white text-lg">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
packages/tablo-views/src/RoadmapSection.tsx
Normal file
23
packages/tablo-views/src/RoadmapSection.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
|
||||
import { GanttChart } from "./components/gantt/GanttChart";
|
||||
|
||||
interface RoadmapSectionProps {
|
||||
tabloTasks: KanbanTask[];
|
||||
onDateClick: (date: Date) => void;
|
||||
onTaskStatusChange: (taskId: string, status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
export function RoadmapSection({
|
||||
tabloTasks,
|
||||
onDateClick,
|
||||
onTaskStatusChange,
|
||||
}: RoadmapSectionProps) {
|
||||
return (
|
||||
<GanttChart
|
||||
tasks={tabloTasks}
|
||||
isLoading={false}
|
||||
onDateClick={onDateClick}
|
||||
onTaskStatusChange={onTaskStatusChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,26 @@
|
|||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { useEffect } from "react";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { useChat } from "./hooks/useChat";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface TabloDiscussionSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
currentUserId: string;
|
||||
members?: Member[];
|
||||
}
|
||||
|
||||
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
|
||||
const user = useUser();
|
||||
export const TabloDiscussionSection = ({
|
||||
tablo,
|
||||
currentUserId,
|
||||
members = [],
|
||||
}: TabloDiscussionSectionProps) => {
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
|
|
@ -22,8 +31,6 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) =
|
|||
markAsRead,
|
||||
} = useChat(tablo.id);
|
||||
|
||||
const { data: members = [] } = useTabloMembers(tablo.id);
|
||||
|
||||
// Mark as read when opening the discussion
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
|
|
@ -36,7 +43,7 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) =
|
|||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
currentUserId={user.id}
|
||||
currentUserId={currentUserId}
|
||||
members={members}
|
||||
typingUsers={typingUsers}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
|
|
@ -1,23 +1,74 @@
|
|||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { Calendar, Clock, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEventsByTablo } from "../hooks/events";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
|
||||
interface TabloEvent {
|
||||
event_id: string;
|
||||
title: string;
|
||||
start_date: string;
|
||||
end_date?: string | null;
|
||||
start_time?: string | null;
|
||||
end_time?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string | null;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface TabloEventsSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
isReadOnly?: boolean;
|
||||
events?: TabloEvent[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
currentUser: CurrentUser;
|
||||
members?: Member[];
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
onCreateEvent?: () => void;
|
||||
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) => {
|
||||
const navigate = useNavigate();
|
||||
export const TabloEventsSection = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
isReadOnly = false,
|
||||
events,
|
||||
isLoading,
|
||||
error,
|
||||
currentUser,
|
||||
members,
|
||||
pendingInvites,
|
||||
isInvitingUser,
|
||||
isCancellingInvite,
|
||||
onCreateEvent,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloEventsSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: events, isLoading, error } = useEventsByTablo(tablo.id);
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
|
||||
// Filter upcoming events (events in the future or today)
|
||||
const today = new Date();
|
||||
|
|
@ -34,10 +85,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
return (a.start_time || "").localeCompare(b.start_time || "");
|
||||
});
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
navigate(`/planning/create?tablo_id=${tablo.id}`);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
|
|
@ -50,7 +97,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
|
||||
const formatTime = (timeStr: string) => {
|
||||
if (!timeStr) return "";
|
||||
|
||||
return timeStr.slice(0, 5); // HH:MM
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +112,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
</TypographyMuted>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
onClick={handleCreateEvent}
|
||||
onClick={onCreateEvent}
|
||||
className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
|
|
@ -74,7 +120,18 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloHeaderActions
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onUpdateTablo={onUpdateTablo}
|
||||
onInviteUser={onInviteUser}
|
||||
onCancelInvite={onCancelInvite}
|
||||
/>
|
||||
</div>
|
||||
{/* Events List */}
|
||||
<div className="bg-card rounded-lg border border-border">
|
||||
|
|
@ -176,7 +233,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
</p>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
onClick={handleCreateEvent}
|
||||
onClick={onCreateEvent}
|
||||
className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { TabloFolder } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Collapsible,
|
||||
|
|
@ -26,28 +27,22 @@ import {
|
|||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCreateTabloFile,
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
useTabloFileNames,
|
||||
} from "../hooks/tablo_data";
|
||||
import {
|
||||
extractFolderIdFromFileName,
|
||||
getFileNameWithoutFolder,
|
||||
getFolderFilePrefix,
|
||||
TabloFolder,
|
||||
useCreateTabloFolder,
|
||||
useDeleteTabloFolder,
|
||||
useTabloFolders,
|
||||
useUpdateTabloFolder,
|
||||
} from "../hooks/tablo_folders";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
|
||||
interface TabloFilesSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
// Helper to extract folder ID from a file name
|
||||
function extractFolderIdFromFileName(fileName: string): string | null {
|
||||
const match = fileName.match(/^folder_([^_]+)_/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// Helper to strip folder prefix from file name
|
||||
function getFileNameWithoutFolder(fileName: string): string {
|
||||
return fileName.replace(/^folder_[^_]+_/, "");
|
||||
}
|
||||
|
||||
// Helper to build folder file prefix
|
||||
function getFolderFilePrefix(folderId: string): string {
|
||||
return `folder_${folderId}_`;
|
||||
}
|
||||
|
||||
// Helper to get file icon color based on extension
|
||||
|
|
@ -202,7 +197,6 @@ const FolderDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Reset form when dialog opens
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setName(folder?.name || "");
|
||||
|
|
@ -453,19 +447,81 @@ const FolderSection = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
|
||||
const currentUser = useUser();
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: filesLoading,
|
||||
error: filesError,
|
||||
} = useTabloFileNames(tablo.id);
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: foldersLoading,
|
||||
error: foldersError,
|
||||
} = useTabloFolders(tablo.id);
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string | null;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface TabloFilesSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
isReadOnly?: boolean;
|
||||
currentUserId: string;
|
||||
fileNames?: string[];
|
||||
filesLoading?: boolean;
|
||||
filesError?: Error | null;
|
||||
folders?: TabloFolder[];
|
||||
foldersLoading?: boolean;
|
||||
foldersError?: Error | null;
|
||||
currentUser: CurrentUser;
|
||||
members?: Member[];
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
isCreatingFolder?: boolean;
|
||||
isUpdatingFolder?: boolean;
|
||||
onCreateFile?: (params: { tabloId: string; fileName: string; data: { content: string; contentType: string } }) => Promise<void>;
|
||||
onDeleteFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
|
||||
onDownloadFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
|
||||
onCreateFolder?: (params: { tabloId: string; name: string; description: string; createdBy: string }) => Promise<void>;
|
||||
onUpdateFolder?: (params: { tabloId: string; folderId: string; name: string; description: string }) => Promise<void>;
|
||||
onDeleteFolder?: (params: { tabloId: string; folderId: string; folderName: string }) => Promise<void>;
|
||||
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloFilesSection = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
isReadOnly = false,
|
||||
currentUserId,
|
||||
fileNames,
|
||||
filesLoading,
|
||||
filesError,
|
||||
folders = [],
|
||||
foldersLoading,
|
||||
foldersError,
|
||||
currentUser,
|
||||
members,
|
||||
pendingInvites,
|
||||
isInvitingUser,
|
||||
isCancellingInvite,
|
||||
isCreatingFolder = false,
|
||||
isUpdatingFolder = false,
|
||||
onCreateFile,
|
||||
onDeleteFile,
|
||||
onDownloadFile,
|
||||
onCreateFolder,
|
||||
onUpdateFolder,
|
||||
onDeleteFolder,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloFilesSectionProps) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadingToFolder, setUploadingToFolder] = useState<string | null>(null);
|
||||
|
|
@ -477,27 +533,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
const [editingFolder, setEditingFolder] = useState<TabloFolder | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const createFile = useCreateTabloFile();
|
||||
const deleteFile = useDeleteTabloFile();
|
||||
const downloadFile = useDownloadTabloFile();
|
||||
const createFolder = useCreateTabloFolder();
|
||||
const updateFolder = useUpdateTabloFolder();
|
||||
const deleteFolder = useDeleteTabloFolder();
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
const folders = foldersData?.folders || [];
|
||||
const folderIds = useMemo(() => new Set(folders.map((folder) => folder.id)), [folders]);
|
||||
|
||||
// Organize files by folder
|
||||
const { filesInFolders, unorganizedFiles } = useMemo(() => {
|
||||
if (!fileData?.fileNames) {
|
||||
if (!fileNames) {
|
||||
return { filesInFolders: new Map<string, string[]>(), unorganizedFiles: [] };
|
||||
}
|
||||
|
||||
const filesInFolders = new Map<string, string[]>();
|
||||
const unorganizedFiles: string[] = [];
|
||||
|
||||
for (const fileName of fileData.fileNames) {
|
||||
// Skip metadata files
|
||||
for (const fileName of fileNames) {
|
||||
if (fileName.startsWith(".")) continue;
|
||||
|
||||
const folderId = extractFolderIdFromFileName(fileName);
|
||||
|
|
@ -511,7 +558,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
}
|
||||
|
||||
return { filesInFolders, unorganizedFiles };
|
||||
}, [fileData?.fileNames, folderIds]);
|
||||
}, [fileNames, folderIds]);
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
setOpenFolders((prev) => {
|
||||
|
|
@ -558,7 +605,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
? `${getFolderFilePrefix(targetFolderId)}${file.name}`
|
||||
: file.name;
|
||||
|
||||
await createFile.mutateAsync({
|
||||
await onCreateFile?.({
|
||||
tabloId: tablo.id,
|
||||
fileName,
|
||||
data: {
|
||||
|
|
@ -625,7 +672,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
setDeletingFile(fileName);
|
||||
try {
|
||||
await deleteFile.mutateAsync({ tabloId: tablo.id, fileName });
|
||||
await onDeleteFile?.({ tabloId: tablo.id, fileName });
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
} finally {
|
||||
|
|
@ -638,7 +685,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
setDownloadingFile(fileName);
|
||||
try {
|
||||
await downloadFile.mutateAsync({ tabloId: tablo.id, fileName });
|
||||
await onDownloadFile?.({ tabloId: tablo.id, fileName });
|
||||
} catch (error) {
|
||||
console.error("Download error:", error);
|
||||
} finally {
|
||||
|
|
@ -665,7 +712,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
if (!window.confirm(confirmMessage)) return;
|
||||
|
||||
await deleteFolder.mutateAsync({
|
||||
await onDeleteFolder?.({
|
||||
tabloId: tablo.id,
|
||||
folderId: folder.id,
|
||||
folderName: folder.name,
|
||||
|
|
@ -674,18 +721,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
const handleSaveFolder = async (name: string, description: string) => {
|
||||
if (editingFolder) {
|
||||
await updateFolder.mutateAsync({
|
||||
await onUpdateFolder?.({
|
||||
tabloId: tablo.id,
|
||||
folderId: editingFolder.id,
|
||||
name,
|
||||
description,
|
||||
});
|
||||
} else {
|
||||
await createFolder.mutateAsync({
|
||||
await onCreateFolder?.({
|
||||
tabloId: tablo.id,
|
||||
name,
|
||||
description,
|
||||
createdBy: currentUser?.id || "",
|
||||
createdBy: currentUserId,
|
||||
});
|
||||
}
|
||||
setIsFolderDialogOpen(false);
|
||||
|
|
@ -703,7 +750,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
Gérez les fichiers et livrables de ce tablo
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloHeaderActions
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onUpdateTablo={onUpdateTablo}
|
||||
onInviteUser={onInviteUser}
|
||||
onCancelInvite={onCancelInvite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
|
|
@ -987,7 +1045,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
}}
|
||||
onSave={handleSaveFolder}
|
||||
folder={editingFolder}
|
||||
isLoading={createFolder.isPending || updateFolder.isPending}
|
||||
isLoading={isCreatingFolder || isUpdatingFolder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -13,21 +13,52 @@ import { Input } from "@xtablo/ui/components/input";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover";
|
||||
import { Loader2, Settings, Share2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useTabloMembers, useUpdateTablo } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string | null;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface TabloHeaderActionsProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
currentUser: CurrentUser;
|
||||
members?: Member[];
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
onUpdateTablo?: (data: TabloUpdate & { id: string }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) => {
|
||||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
const currentUser = useUser();
|
||||
export const TabloHeaderActions = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
currentUser,
|
||||
members = [],
|
||||
pendingInvites = [],
|
||||
isInvitingUser = false,
|
||||
isCancellingInvite = false,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloHeaderActionsProps) => {
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
|
||||
|
|
@ -39,12 +70,6 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch members and invites for share dialog
|
||||
const { data: members } = useTabloMembers(tablo?.id || "");
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || "");
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
useEffect(() => {
|
||||
setEditData(tablo);
|
||||
setSelectedColor(tablo.color || "bg-blue-500");
|
||||
|
|
@ -59,14 +84,14 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
}, [isEditingName]);
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
if (editData && tablo) {
|
||||
if (editData && tablo && onUpdateTablo) {
|
||||
const updatedTablo: TabloUpdate & { id: string } = {
|
||||
id: editData.id,
|
||||
name: editData.name,
|
||||
color: creationMode === "color" ? selectedColor : null,
|
||||
};
|
||||
try {
|
||||
await updateTablo(updatedTablo);
|
||||
await onUpdateTablo(updatedTablo);
|
||||
toast.add(
|
||||
{
|
||||
title: "Tablo mis à jour",
|
||||
|
|
@ -89,8 +114,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
};
|
||||
|
||||
const handleSendInvite = () => {
|
||||
if (inviteEmail.trim() && tablo) {
|
||||
inviteUser({ email: inviteEmail, tablo_id: tablo.id });
|
||||
if (inviteEmail.trim() && tablo && onInviteUser) {
|
||||
onInviteUser({ email: inviteEmail, tablo_id: tablo.id });
|
||||
setInviteEmail("");
|
||||
}
|
||||
};
|
||||
|
|
@ -278,7 +303,7 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })}
|
||||
onClick={() => onCancelInvite?.({ tabloId: tablo.id, inviteId: invite.id })}
|
||||
disabled={isCancellingInvite}
|
||||
title="Retirer l'invitation"
|
||||
>
|
||||
|
|
@ -1,37 +1,71 @@
|
|||
import { pluralize, toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type {
|
||||
Etape,
|
||||
KanbanColumn,
|
||||
KanbanTask,
|
||||
KanbanTaskInsert,
|
||||
KanbanTaskUpdate,
|
||||
TaskStatus,
|
||||
} from "@xtablo/shared-types";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { AlertTriangle, ListChecks } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import {
|
||||
useCreateTask,
|
||||
useTabloEtapes,
|
||||
useTasksByTablo,
|
||||
useUpdateTaskPositions,
|
||||
} from "../hooks/tasks";
|
||||
import { KanbanBoard } from "./kanban/KanbanBoard";
|
||||
import { TaskModal } from "./kanban/TaskModal";
|
||||
import { KanbanBoard } from "./components/kanban/KanbanBoard";
|
||||
import type { TabloMember } from "./components/kanban/types";
|
||||
import { TaskModal } from "./components/kanban/TaskModal";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface TabloTasksSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
tasks?: KanbanTask[];
|
||||
members?: TabloMember[];
|
||||
etapes?: Etape[];
|
||||
currentUser: CurrentUser;
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
onCreateTask?: (task: KanbanTaskInsert) => void;
|
||||
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
|
||||
onUpdateTaskPositions?: (updates: Array<{ id: string; position: number; status: TaskStatus }>) => void;
|
||||
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => {
|
||||
const { data: members = [] } = useTabloMembers(tablo.id);
|
||||
export const TabloTasksSection = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
tasks,
|
||||
members = [],
|
||||
etapes = [],
|
||||
currentUser,
|
||||
pendingInvites,
|
||||
isInvitingUser,
|
||||
isCancellingInvite,
|
||||
onCreateTask,
|
||||
onUpdateTask,
|
||||
onUpdateTaskPositions,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloTasksSectionProps) => {
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<KanbanTask | null>(null);
|
||||
const [modalStatus, setModalStatus] = useState<TaskStatus>("todo");
|
||||
|
||||
const { data: tasks } = useTasksByTablo(tablo.id);
|
||||
const { data: etapes = [] } = useTabloEtapes(tablo.id);
|
||||
const { mutate: updateTaskPositions } = useUpdateTaskPositions();
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
|
||||
const memberById = useMemo(
|
||||
() => new Map(members.map((member) => [member.id, member])),
|
||||
[members]
|
||||
|
|
@ -72,7 +106,6 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
return tasksWithAssigneeFallback.filter((task) => !task.parent_task_id);
|
||||
}, [tasksWithAssigneeFallback]);
|
||||
|
||||
// Helper functions defined before use
|
||||
const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => {
|
||||
const defaultColumns: KanbanColumn[] = [
|
||||
{
|
||||
|
|
@ -137,19 +170,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
parent_task_id: taskData.parent_task_id ?? null,
|
||||
};
|
||||
|
||||
createTask(newTask);
|
||||
|
||||
// setColumns((prevColumns) =>
|
||||
// prevColumns.map((column: KanbanColumn) => {
|
||||
// if (column.status === (taskData.status as TaskStatus)) {
|
||||
// return {
|
||||
// ...column,
|
||||
// tasks: [newTask, ...column.tasks],
|
||||
// };
|
||||
// }
|
||||
// return column;
|
||||
// })
|
||||
// );
|
||||
onCreateTask?.(newTask);
|
||||
|
||||
toast.add(
|
||||
{
|
||||
|
|
@ -162,7 +183,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
};
|
||||
|
||||
const handleTaskMove = (taskId: string, newStatus: TaskStatus) => {
|
||||
updateTaskPositions([
|
||||
onUpdateTaskPositions?.([
|
||||
{
|
||||
id: taskId,
|
||||
position: columns.find((column) => column.status === newStatus)?.position ?? 0,
|
||||
|
|
@ -198,7 +219,18 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
Gérez vos tâches avec un tableau Kanban
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloHeaderActions
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onUpdateTablo={onUpdateTablo}
|
||||
onInviteUser={onInviteUser}
|
||||
onCancelInvite={onCancelInvite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning for orphaned tasks */}
|
||||
|
|
@ -238,11 +270,14 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
<TaskModal
|
||||
tabloId={tablo.id}
|
||||
taskId={selectedTask?.id}
|
||||
task={selectedTask}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
members={members}
|
||||
initialStatus={modalStatus}
|
||||
etapes={etapes}
|
||||
onCreateTask={onCreateTask}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -253,7 +252,7 @@ export function GanttChart({ tasks, isLoading, onDateClick, onTaskStatusChange }
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -130,24 +130,6 @@ export const InlineTaskCreate = ({ status, members, etapes, onSubmit }: InlineTa
|
|||
|
||||
{/* Type and Assignee */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* <div className="space-y-1">
|
||||
<Label htmlFor="type" className="text-xs text-muted-foreground">
|
||||
Type
|
||||
</Label>
|
||||
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
|
||||
<SelectTrigger id="type" size="sm" className="w-full text-sm h-8">
|
||||
<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> */}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="assignee" className="text-xs text-muted-foreground">
|
||||
Assigné à
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, TaskStatus } from "@xtablo/shared-types";
|
||||
import type { Etape, KanbanTask, KanbanTaskInsert, KanbanTaskUpdate, 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";
|
||||
|
|
@ -15,21 +14,32 @@ 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 { useTabloMembers } from "../../hooks/tablos";
|
||||
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
|
||||
import type { TabloMember } from "./types";
|
||||
|
||||
/** Minimal UserTablo shape needed by this modal (tablo selector when creating). */
|
||||
interface MinimalTablo {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
tabloId?: string; // Optional when creating a task - can select tablo
|
||||
taskId?: string | undefined; // Optional - undefined when creating new task
|
||||
tabloId?: string;
|
||||
taskId?: string | undefined;
|
||||
onClose: () => void;
|
||||
members?: TabloMember[]; // Optional - will be fetched if tabloId is provided
|
||||
/** Task data when editing an existing task. */
|
||||
task?: KanbanTask | null;
|
||||
members?: TabloMember[];
|
||||
etapes?: Etape[];
|
||||
initialStatus?: TaskStatus;
|
||||
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
|
||||
tablos?: MinimalTablo[];
|
||||
allowTabloSelection?: boolean;
|
||||
initialDueDate?: Date;
|
||||
/** Called when creating a new task. */
|
||||
onCreateTask?: (task: KanbanTaskInsert) => void;
|
||||
/** Called when updating an existing task. */
|
||||
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
|
||||
}
|
||||
|
||||
export const TaskModal = ({
|
||||
|
|
@ -37,14 +47,16 @@ export const TaskModal = ({
|
|||
taskId,
|
||||
isOpen,
|
||||
onClose,
|
||||
members: providedMembers,
|
||||
task = null,
|
||||
members: providedMembers = [],
|
||||
initialStatus = "todo",
|
||||
etapes: providedEtapes,
|
||||
etapes: providedEtapes = [],
|
||||
tablos,
|
||||
allowTabloSelection = false,
|
||||
initialDueDate,
|
||||
onCreateTask,
|
||||
onUpdateTask,
|
||||
}: TaskModalProps) => {
|
||||
const { data: task = null } = useTask(taskId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
|
||||
|
|
@ -54,16 +66,6 @@ export const TaskModal = ({
|
|||
initialTabloId || tablos?.[0]?.id || ""
|
||||
);
|
||||
|
||||
// Determine which tablo to use for fetching data
|
||||
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);
|
||||
|
||||
// Use provided or fetched data
|
||||
const members = providedMembers || fetchedMembers;
|
||||
const etapes = providedEtapes || fetchedEtapes;
|
||||
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -77,7 +79,6 @@ export const TaskModal = ({
|
|||
setSelectedTabloId(task.tablo_id);
|
||||
}
|
||||
} else {
|
||||
// Reset form when creating new task
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAssigneeId("unassigned");
|
||||
|
|
@ -89,9 +90,6 @@ export const TaskModal = ({
|
|||
}
|
||||
}, [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;
|
||||
|
|
@ -104,12 +102,12 @@ export const TaskModal = ({
|
|||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (!currentTabloId) return; // Need a tablo to create task
|
||||
if (!currentTabloId) return;
|
||||
|
||||
const dueDateValue = formatDateForDb(dueDate);
|
||||
|
||||
if (taskId && task) {
|
||||
updateTask({
|
||||
onUpdateTask?.({
|
||||
tablo_id: task.tablo_id,
|
||||
id: task.id,
|
||||
title: title.trim(),
|
||||
|
|
@ -120,7 +118,7 @@ export const TaskModal = ({
|
|||
due_date: dueDateValue,
|
||||
});
|
||||
} else {
|
||||
createTask({
|
||||
onCreateTask?.({
|
||||
tablo_id: currentTabloId,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
|
|
@ -223,7 +221,7 @@ export const TaskModal = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">Non assigné</SelectItem>
|
||||
{members.map((member) => (
|
||||
{providedMembers.map((member) => (
|
||||
<SelectItem key={member.id} value={member.id}>
|
||||
{member.name}
|
||||
</SelectItem>
|
||||
|
|
@ -233,7 +231,7 @@ export const TaskModal = ({
|
|||
</div>
|
||||
|
||||
{/* Étape */}
|
||||
{etapes.length > 0 && (
|
||||
{providedEtapes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="etape">Étape</Label>
|
||||
<Select value={etapeId} onValueChange={setEtapeId}>
|
||||
|
|
@ -242,7 +240,7 @@ export const TaskModal = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Aucune</SelectItem>
|
||||
{etapes.map((etape) => (
|
||||
{providedEtapes.map((etape) => (
|
||||
<SelectItem key={etape.id} value={etape.id}>
|
||||
{etape.title}
|
||||
</SelectItem>
|
||||
20
packages/tablo-views/src/index.ts
Normal file
20
packages/tablo-views/src/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export { TabloTasksSection } from "./TabloTasksSection";
|
||||
export { TabloFilesSection } from "./TabloFilesSection";
|
||||
export { TabloDiscussionSection } from "./TabloDiscussionSection";
|
||||
export { TabloEventsSection } from "./TabloEventsSection";
|
||||
export { EtapesSection } from "./EtapesSection";
|
||||
export { RoadmapSection } from "./RoadmapSection";
|
||||
export { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
export { ChatMessages } from "./ChatMessages";
|
||||
|
||||
// Sub-components
|
||||
export { GanttChart } from "./components/gantt/GanttChart";
|
||||
export { TaskModal } from "./components/kanban/TaskModal";
|
||||
export { KanbanBoard } from "./components/kanban/KanbanBoard";
|
||||
|
||||
// Hooks
|
||||
export { useChat } from "./hooks/useChat";
|
||||
export { useChatUnread } from "./hooks/useChatUnread";
|
||||
|
||||
// Types
|
||||
export type { TabloMember } from "./components/kanban/types";
|
||||
1
packages/tablo-views/src/vite-env.d.ts
vendored
Normal file
1
packages/tablo-views/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
28
packages/tablo-views/tsconfig.json
Normal file
28
packages/tablo-views/tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@xtablo/ui": ["../ui/src"],
|
||||
"@xtablo/ui/*": ["../ui/src/*"],
|
||||
"@xtablo/shared": ["../shared/src"],
|
||||
"@xtablo/shared/*": ["../shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "src/vite-env.d.ts"]
|
||||
}
|
||||
143
pnpm-lock.yaml
143
pnpm-lock.yaml
|
|
@ -140,6 +140,91 @@ importers:
|
|||
specifier: ^4.14.0
|
||||
version: 4.44.0(@cloudflare/workers-types@4.20260411.1)
|
||||
|
||||
apps/clients:
|
||||
dependencies:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.69.0
|
||||
version: 5.90.5(react@19.0.0)
|
||||
'@xtablo/chat-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/chat-ui
|
||||
'@xtablo/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@xtablo/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-types
|
||||
'@xtablo/tablo-views':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/tablo-views
|
||||
'@xtablo/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
i18next:
|
||||
specifier: ^25.6.0
|
||||
version: 25.6.0(typescript@5.9.3)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
lucide-react:
|
||||
specifier: ^0.460.0
|
||||
version: 0.460.0(react@19.0.0)
|
||||
react:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0
|
||||
react-dom:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
react-i18next:
|
||||
specifier: ^16.2.0
|
||||
version: 16.2.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
|
||||
react-router-dom:
|
||||
specifier: ^7.9.4
|
||||
version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.0.2
|
||||
version: 3.3.1
|
||||
zustand:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.8(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.6.0(react@19.0.0))
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.2.5
|
||||
version: 2.2.5
|
||||
'@cloudflare/vite-plugin':
|
||||
specifier: ^1.9.4
|
||||
version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.0.14
|
||||
version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
|
||||
'@types/react':
|
||||
specifier: 19.0.10
|
||||
version: 19.0.10
|
||||
'@types/react-dom':
|
||||
specifier: 19.0.4
|
||||
version: 19.0.4(@types/react@19.0.10)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.7.0(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
|
||||
tailwindcss:
|
||||
specifier: ^4.0.14
|
||||
version: 4.1.15
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.2.2
|
||||
version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
|
||||
wrangler:
|
||||
specifier: ^4.24.3
|
||||
version: 4.44.0(@cloudflare/workers-types@4.20260411.1)
|
||||
|
||||
apps/external:
|
||||
dependencies:
|
||||
'@tanstack/react-query':
|
||||
|
|
@ -314,6 +399,9 @@ importers:
|
|||
'@xtablo/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-types
|
||||
'@xtablo/tablo-views':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/tablo-views
|
||||
'@xtablo/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
|
|
@ -642,6 +730,61 @@ importers:
|
|||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/tablo-views:
|
||||
dependencies:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.69.0
|
||||
version: 5.90.5(react@19.0.0)
|
||||
'@xtablo/chat-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../chat-ui
|
||||
'@xtablo/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@xtablo/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../shared-types
|
||||
'@xtablo/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../ui
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
lucide-react:
|
||||
specifier: ^0.460.0
|
||||
version: 0.460.0(react@19.0.0)
|
||||
react:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0
|
||||
react-dom:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
react-i18next:
|
||||
specifier: ^16.2.0
|
||||
version: 16.2.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
|
||||
react-router-dom:
|
||||
specifier: ^7.9.4
|
||||
version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.0.2
|
||||
version: 3.3.1
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.2.5
|
||||
version: 2.2.5
|
||||
'@types/react':
|
||||
specifier: 19.0.10
|
||||
version: 19.0.10
|
||||
'@types/react-dom':
|
||||
specifier: 19.0.4
|
||||
version: 19.0.4(@types/react@19.0.10)
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.2.2
|
||||
version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@floating-ui/react':
|
||||
|
|
|
|||
40
supabase/migrations/20260415120000_add_client_invites.sql
Normal file
40
supabase/migrations/20260415120000_add_client_invites.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- Add is_client column to profiles
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN is_client boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Create client_invites table
|
||||
CREATE TABLE public.client_invites (
|
||||
id serial PRIMARY KEY,
|
||||
tablo_id text NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE,
|
||||
invited_email varchar(255) NOT NULL,
|
||||
invited_by uuid NOT NULL REFERENCES public.profiles(id),
|
||||
invite_token text NOT NULL,
|
||||
expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
|
||||
is_pending boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Index for token lookups
|
||||
CREATE UNIQUE INDEX idx_client_invites_token ON public.client_invites(invite_token);
|
||||
|
||||
-- Index for listing invites by tablo
|
||||
CREATE INDEX idx_client_invites_tablo ON public.client_invites(tablo_id, is_pending);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.client_invites ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admins can manage invites they created
|
||||
CREATE POLICY "Admins can manage their client invites"
|
||||
ON public.client_invites
|
||||
FOR ALL
|
||||
USING (invited_by = auth.uid());
|
||||
|
||||
-- Client users can read invites sent to their email
|
||||
CREATE POLICY "Clients can read their own invites"
|
||||
ON public.client_invites
|
||||
FOR SELECT
|
||||
USING (
|
||||
invited_email = (
|
||||
SELECT email FROM auth.users WHERE id = auth.uid()
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue