diff --git a/apps/clients/src/components/ClientLayout.tsx b/apps/clients/src/components/ClientLayout.tsx new file mode 100644 index 0000000..20719a0 --- /dev/null +++ b/apps/clients/src/components/ClientLayout.tsx @@ -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 ( +
+
+

Accès non autorisé

+

+ Veuillez utiliser le lien reçu dans votre email pour accéder à cette page. +

+
+
+ ); + } + + const email = session.user.email ?? ""; + const initials = email ? getInitials(email) : "?"; + + const handleLogout = async () => { + await supabase.auth.signOut(); + }; + + return ( +
+ {/* Top bar */} +
+
+ {/* Brand */} + Xtablo + + {/* User info + logout */} +
+
+ + {initials} + + {email} +
+ +
+
+
+ + {/* Page content */} +
+ +
+
+ ); +} diff --git a/apps/clients/src/lib/supabase.ts b/apps/clients/src/lib/supabase.ts new file mode 100644 index 0000000..99c2e17 --- /dev/null +++ b/apps/clients/src/lib/supabase.ts @@ -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); diff --git a/apps/clients/src/main.tsx b/apps/clients/src/main.tsx index ecf1020..d158df1 100644 --- a/apps/clients/src/main.tsx +++ b/apps/clients/src/main.tsx @@ -1,11 +1,13 @@ 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"; @@ -14,12 +16,14 @@ import "./i18n"; createRoot(document.getElementById("client-root")!).render( - - - - - - + + + + + + + + ); diff --git a/apps/clients/src/pages/AuthCallback.tsx b/apps/clients/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..b34427b --- /dev/null +++ b/apps/clients/src/pages/AuthCallback.tsx @@ -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(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 ( +
+
+

Erreur

+

{error}

+
+
+ ); + } + + return ( +
+
+
+

Authentification en cours...

+
+
+ ); +} diff --git a/apps/clients/src/pages/ClientTabloListPage.tsx b/apps/clients/src/pages/ClientTabloListPage.tsx new file mode 100644 index 0000000..e3ce7c6 --- /dev/null +++ b/apps/clients/src/pages/ClientTabloListPage.tsx @@ -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({ + 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 ( +
+
+
+ ); + } + + if (!tablos || tablos.length === 0) { + return ( +
+

Aucun projet disponible.

+
+ ); + } + + if (tablos.length === 1) { + return ; + } + + return ( +
+
+

Mes projets

+

Sélectionnez un projet pour y accéder.

+
+ +
+ {tablos.map((tablo) => ( + + {tablo.color && ( +
+ )} +

{tablo.name}

+ + ))} +
+
+ ); +} diff --git a/apps/clients/src/pages/ClientTabloPage.tsx b/apps/clients/src/pages/ClientTabloPage.tsx new file mode 100644 index 0000000..3da9be0 --- /dev/null +++ b/apps/clients/src/pages/ClientTabloPage.tsx @@ -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({ + 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({ + 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({ + 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({ + 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("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 ( +
+
+
+ ); + } + + if (!tablo) { + return ( +
+

Projet introuvable.

+
+ ); + } + + return ( +
+ {/* Tablo header */} +
+

{tablo.name}

+
+ + {/* Tab bar */} +
+ +
+ + {/* Tab content */} +
+ {activeTab === "overview" && ( +
+ {/* Simple overview: list etapes with progress */} + {}} + onCreateEtape={async () => {}} + /> +
+ )} + + {activeTab === "etapes" && ( + {}} + onCreateEtape={async () => {}} + /> + )} + + {activeTab === "tasks" && ( + + )} + + {activeTab === "files" && ( + + )} + + {activeTab === "discussion" && ( + + )} + + {activeTab === "events" && ( + [0]["events"]} + isLoading={eventsLoading} + error={eventsError instanceof Error ? eventsError : null} + currentUser={currentUser} + members={members} + /> + )} + + {activeTab === "roadmap" && ( + {}} + onTaskStatusChange={() => {}} + /> + )} +
+
+ ); +} diff --git a/apps/clients/src/routes.tsx b/apps/clients/src/routes.tsx index 4f94f7f..57a23ce 100644 --- a/apps/clients/src/routes.tsx +++ b/apps/clients/src/routes.tsx @@ -1,11 +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 ( - Auth callback placeholder
} /> - Tablo view placeholder
} /> - Client portal placeholder
} /> + } /> + }> + } /> + } /> + ); } diff --git a/apps/clients/tsconfig.json b/apps/clients/tsconfig.json index 64a1401..f2fa327 100644 --- a/apps/clients/tsconfig.json +++ b/apps/clients/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",