diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts index 2bb8546..2e18619 100644 --- a/apps/api/src/routers/tablo_data.ts +++ b/apps/api/src/routers/tablo_data.ts @@ -36,6 +36,39 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => } }); +// Returns file names for all tablos the authenticated user has access to, in one request +const getAllTablosFilenames = factory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + const s3_client = c.get("s3_client"); + + try { + const { data: tabloAccess, error } = await supabase + .from("tablo_access") + .select("tablo_id") + .eq("user_id", user.id) + .eq("is_active", true); + + if (error) { + return c.json({ error: "Failed to fetch tablos" }, 500); + } + + const tabloIds = (tabloAccess ?? []).map((row: { tablo_id: string }) => row.tablo_id); + + const results = await Promise.all( + tabloIds.map(async (tabloId: string) => { + const fileNames = await getTabloFileNames(s3_client, tabloId); + return { tabloId, fileNames: fileNames ?? [] }; + }) + ); + + return c.json({ tablos: results }); + } catch (error) { + console.error("Error fetching all tablo files:", error); + return c.json({ error: "Failed to fetch all tablo files" }, 500); + } +}); + const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); // Get the file path - supports both wildcard (*) and named parameter (:fileName) @@ -338,6 +371,9 @@ export const getTabloDataRouter = () => { tabloDataRouter.use(middlewareManager.streamChat); tabloDataRouter.use(middlewareManager.r2); + // All-tablos file listing (must be before /:tabloId routes) + tabloDataRouter.get("/all-filenames", ...getAllTablosFilenames); + // File endpoints tabloDataRouter.get("/:tabloId/filenames", ...getTabloFilenames); diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index bf3bac3..b633b23 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -20,6 +20,7 @@ import { ConstructionIcon, CreditCard, // FileTextIcon, // Notes feature temporarily hidden + FolderIcon, Kanban, LayersIcon, ListTodo, @@ -33,6 +34,7 @@ import { Sparkles, SquareKanban, } from "lucide-react"; +import { useTablosList } from "../hooks/tablos"; import { useState } from "react"; import { Separator } from "react-aria-components"; import { useTranslation } from "react-i18next"; @@ -288,6 +290,52 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean ); }; +function RecentProjectsSection() { + const { t } = useTranslation("navigation"); + const location = useLocation(); + const { data: tablos } = useTablosList(); + const recentTablos = (tablos ?? []).slice(0, 4); + + if (recentTablos.length === 0) return null; + + return ( +
+ +
+ + {t("projects", "Projects")} + +
+ +
+ ); +} + export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { const location = useLocation(); const isReadOnly = useIsReadOnlyUser(); @@ -370,6 +418,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { label: t("discussions"), icon: , }, + { + path: "/files", + label: t("files", "Fichiers"), + icon: , + }, // Notes feature temporarily hidden // { // path: "/notes", @@ -418,6 +471,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { ) : null; })} + + {/* Recent projects section */} + {!isCollapsed && } +
    {/* Trial upsell message */} {shouldShowTrialUpsell && !isCollapsed && ( @@ -501,7 +558,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { Plan Freemium

    - Passer au plan Starter pour profiter de tablos illimités. + Passer au plan Starter pour profiter de projets illimités.

    @@ -573,9 +630,9 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { -
  • - -
  • + +
  • +
diff --git a/apps/main/src/hooks/tablo_data.ts b/apps/main/src/hooks/tablo_data.ts index 1112459..0aa8578 100644 --- a/apps/main/src/hooks/tablo_data.ts +++ b/apps/main/src/hooks/tablo_data.ts @@ -31,6 +31,25 @@ export const toastOptions = { timeout: toastTimeout, }; +export interface AllTablosFileNames { + tablos: { tabloId: string; fileNames: string[] }[]; +} + +// Hook to get file names for all tablos in a single request +export function useAllTablosFileNames() { + const api = useAuthedApi(); + return useQuery({ + queryKey: ["all-tablo-files"], + queryFn: async () => { + const response = await api.get("/api/v1/tablo-data/all-filenames"); + if (response.status !== 200) { + throw new Error("Failed to fetch all tablo files"); + } + return response.data; + }, + }); +} + // Hook to get all file names for a tablo export function useTabloFileNames(tabloId: string) { const api = useAuthedApi(); diff --git a/apps/main/src/lib/routes.tsx b/apps/main/src/lib/routes.tsx index 7fff7be..5bf3124 100644 --- a/apps/main/src/lib/routes.tsx +++ b/apps/main/src/lib/routes.tsx @@ -23,6 +23,7 @@ import { TabloPage } from "../pages/tablo"; import { TabloDetailsPage } from "../pages/tablo-details"; import { TablosPage } from "../pages/tablos"; import { TasksPage } from "../pages/tasks"; +import { FilesPage } from "../pages/files"; import { UpdatePasswordPage } from "../pages/update-password"; import ChatProvider from "../providers/ChatProvider"; import { EventsPage } from "src/pages/events"; @@ -101,6 +102,10 @@ export const routes: RouteObject[] = [ path: "tasks", element: , }, + { + path: "files", + element: , + }, { path: "tablos", element: , diff --git a/apps/main/src/locales/en/navigation.json b/apps/main/src/locales/en/navigation.json index b4848dc..424b0eb 100644 --- a/apps/main/src/locales/en/navigation.json +++ b/apps/main/src/locales/en/navigation.json @@ -1,12 +1,13 @@ { "home": "Home", - "tablos": "Tablos", - "projects": "Home", + "tablos": "Projects", + "projects": "Projects", "myEvents": "My Events", "planning": "Planning", "tasks": "Tasks", "discussions": "Discussions", "notes": "Notes", + "files": "Files", "feedback": "Feedback", "settings": "Settings", "availabilities": "Availabilities", diff --git a/apps/main/src/locales/fr/navigation.json b/apps/main/src/locales/fr/navigation.json index 159098d..43881cb 100644 --- a/apps/main/src/locales/fr/navigation.json +++ b/apps/main/src/locales/fr/navigation.json @@ -1,12 +1,13 @@ { "home": "Aperçu", - "tablos": "Tablos", - "projects": "Aperçu", + "tablos": "Projets", + "projects": "Projets", "myEvents": "Mes Événements", "planning": "Planning", "tasks": "Tâches", "discussions": "Discussions", "notes": "Notes", + "files": "Fichiers", "feedback": "Feedback", "settings": "Paramètres", "availabilities": "Disponibilités", diff --git a/apps/main/src/pages/files.tsx b/apps/main/src/pages/files.tsx new file mode 100644 index 0000000..14289b6 --- /dev/null +++ b/apps/main/src/pages/files.tsx @@ -0,0 +1,493 @@ +import { toast } from "@xtablo/shared"; +import { LoadingSpinner } from "@ui/components/LoadingSpinner"; +import { Button } from "@xtablo/ui/components/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@xtablo/ui/components/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@xtablo/ui/components/dropdown-menu"; +import { + DownloadIcon, + EllipsisVerticalIcon, + FileTextIcon, + FolderIcon, + LayersIcon, + PlusIcon, + Trash2Icon, +} from "lucide-react"; +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { + extractFolderIdFromFileName, + getFileNameWithoutFolder, + getFolderFilePrefix, + useTabloFolders, +} from "../hooks/tablo_folders"; +import { + useAllTablosFileNames, + useCreateTabloFile, + useDeleteTabloFile, + useDownloadTabloFile, +} from "../hooks/tablo_data"; +import { useTablosList } from "../hooks/tablos"; + +// Derive icon color from file extension +function getFileIconColor(fileName: string): string { + const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; + if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) return "bg-purple-500"; + if (ext === "pdf") return "bg-red-500"; + if (["xlsx", "xls", "csv"].includes(ext)) return "bg-green-600"; + if (["doc", "docx"].includes(ext)) return "bg-blue-500"; + return "bg-gray-500"; +} + +// ─── Upload Modal ──────────────────────────────────────────────────────────── + +function UploadModal({ + isOpen, + onClose, + tablos, +}: { + isOpen: boolean; + onClose: () => void; + tablos: { id: string; name: string; color: string | null }[]; +}) { + const [selectedTabloId, setSelectedTabloId] = useState(tablos[0]?.id ?? ""); + const [selectedFolderId, setSelectedFolderId] = useState(""); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const { data: foldersData } = useTabloFolders(selectedTabloId); + const folders = foldersData?.folders ?? []; + const createFile = useCreateTabloFile(); + + const handleTabloChange = (tabloId: string) => { + setSelectedTabloId(tabloId); + setSelectedFolderId(""); + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !selectedTabloId) return; + + const maxSize = 20 * 1024 * 1024; + if (file.size > maxSize) { + toast.add( + { title: "Erreur", description: "Le fichier ne peut pas dépasser 20MB", type: "error" }, + { timeout: 5000 } + ); + return; + } + + setIsUploading(true); + try { + const content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (ev) => resolve(ev.target?.result as string); + reader.onerror = reject; + if (file.type.startsWith("text/") || file.type === "application/json") { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }); + + const fileName = selectedFolderId + ? `${getFolderFilePrefix(selectedFolderId)}${file.name}` + : file.name; + + await createFile.mutateAsync({ + tabloId: selectedTabloId, + fileName, + data: { content, contentType: file.type || "application/octet-stream" }, + }); + + onClose(); + } catch { + // error handled by hook + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + return ( + !open && onClose()}> + + + Upload a file + +
+ {/* Project selector */} +
+ +
+ {tablos.map((tablo) => ( + + ))} +
+
+ + {/* Folder selector (optional) */} + {folders.length > 0 && ( +
+ +
+ + {folders.map((folder) => ( + + ))} +
+
+ )} + + {/* File picker */} + + +
+
+
+ ); +} + +// ─── Folder cards grid ─────────────────────────────────────────────────────── + +function FolderGrid({ + folders, + folderMap, +}: { + folders: { id: string; name: string; description?: string }[]; + folderMap: Map; +}) { + const visibleFolders = folders.filter((f) => (folderMap.get(f.id) ?? []).length > 0); + if (visibleFolders.length === 0) return null; + + return ( +
+ {visibleFolders.map((folder) => { + const count = folderMap.get(folder.id)?.length ?? 0; + return ( +
+
+
+ +
+
+

+ {folder.name} +

+

+ {count} file{count !== 1 ? "s" : ""} +

+
+
+
+ ); + })} +
+ ); +} + +// ─── Per-project section ───────────────────────────────────────────────────── + +function TabloFilesSection({ + tabloId, + tabloName, + tabloColor, + fileNames, +}: { + tabloId: string; + tabloName: string; + tabloColor: string | null; + fileNames: string[]; +}) { + const { data: foldersData } = useTabloFolders(tabloId); + const { mutate: downloadFile } = useDownloadTabloFile(); + const { mutate: deleteFile } = useDeleteTabloFile(); + + const allFileNames = fileNames.filter((f) => !f.startsWith(".")); + const folders = foldersData?.folders ?? []; + + const folderMap = new Map(); + const rootFiles: string[] = []; + + for (const fileName of allFileNames) { + const folderId = extractFolderIdFromFileName(fileName); + if (folderId) { + if (!folderMap.has(folderId)) folderMap.set(folderId, []); + folderMap.get(folderId)!.push(fileName); + } else { + rootFiles.push(fileName); + } + } + + if (allFileNames.length === 0) return null; + + return ( +
+ {/* Project header */} +
+ + {tabloName.charAt(0).toUpperCase()} + + + {tabloName} + + + {allFileNames.length} file{allFileNames.length !== 1 ? "s" : ""} + +
+ + {/* Folder cards */} + + + {/* Files per folder */} + {folders.map((folder) => { + const folderFiles = folderMap.get(folder.id) ?? []; + if (folderFiles.length === 0) return null; + return ( +
+
+ + {folder.name} + ({folderFiles.length}) +
+ downloadFile({ tabloId, fileName })} + onDelete={(fileName) => deleteFile({ tabloId, fileName })} + /> +
+ ); + })} + + {/* Root files */} + {rootFiles.length > 0 && ( + <> + {folders.length > 0 && ( +
+ Other files + ({rootFiles.length}) +
+ )} + downloadFile({ tabloId, fileName })} + onDelete={(fileName) => deleteFile({ tabloId, fileName })} + /> + + )} +
+ ); +} + +// ─── File table ─────────────────────────────────────────────────────────────── + +function FileTable({ + fileNames, + onDownload, + onDelete, +}: { + fileNames: string[]; + onDownload: (fileName: string) => void; + onDelete: (fileName: string) => void; +}) { + return ( +
+
+ + + + + + + + {fileNames.map((fileName) => { + const displayName = getFileNameWithoutFolder(fileName); + const iconColor = getFileIconColor(displayName); + return ( + + + + + ); + })} + +
File name +
+
+
+ +
+

{displayName}

+
+
+ + + + + + onDownload(fileName)}> + + Download + + onDelete(fileName)} + className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400" + > + + Delete + + + +
+
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export function FilesPage() { + const { t } = useTranslation("navigation"); + const { data: tablos, isLoading: tablosLoading } = useTablosList(); + const { data: allFiles, isLoading: filesLoading } = useAllTablosFileNames(); + const [uploadOpen, setUploadOpen] = useState(false); + + const isLoading = tablosLoading || filesLoading; + + const filesByTabloId = new Map( + (allFiles?.tablos ?? []).map(({ tabloId, fileNames }) => [tabloId, fileNames]) + ); + + const tablosWithFiles = (tablos ?? []).filter((tablo) => { + const files = filesByTabloId.get(tablo.id) ?? []; + return files.some((f) => !f.startsWith(".")); + }); + + return ( +
+ {/* Header */} +
+

{t("files", "Files")}

+ +
+ + {isLoading ? ( +
+ +
+ ) : tablosWithFiles.length === 0 ? ( +
+ +

No files found.

+
+ ) : ( +
+ {tablosWithFiles.map((tablo) => ( + + ))} +
+ )} + + {tablos && tablos.length > 0 && ( + setUploadOpen(false)} + tablos={tablos} + /> + )} +
+ ); +} diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx index b99e4d4..6d5cf38 100644 --- a/apps/main/src/pages/tasks.tsx +++ b/apps/main/src/pages/tasks.tsx @@ -1,16 +1,31 @@ import { LoadingSpinner } from "@ui/components/LoadingSpinner"; -import { getTextColorFromTabloColor } from "@xtablo/shared"; -import { KanbanColumn, KanbanTask } from "@xtablo/shared-types"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@xtablo/ui/components/select"; -import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; +import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types"; import { Button } from "@xtablo/ui/components/button"; -import { Kanban, LayoutGrid, ListTodo, PlusIcon, UserIcon } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@xtablo/ui/components/dropdown-menu"; +import { + CalendarIcon, + CircleCheckIcon, + CircleIcon, + EllipsisVerticalIcon, + KanbanIcon, + ListIcon, + ListTodo, + MessageSquareIcon, + MapIcon, + PaperclipIcon, + PlusIcon, + SearchIcon, + Settings2Icon, + UserIcon, +} from "lucide-react"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -50,9 +65,11 @@ export function TasksPage() { const [statusFilter, setStatusFilter] = useState("all"); const [assigneeFilter, setAssigneeFilter] = useState("all"); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); // Get view mode from URL params, default to "kanban" - const viewMode = (searchParams.get("view") as "kanban" | "aggregated") || "kanban"; + const viewMode = + (searchParams.get("view") as "kanban" | "aggregated") || "kanban"; // Function to update view mode in URL const setViewMode = (mode: "kanban" | "aggregated") => { @@ -89,12 +106,31 @@ export function TasksPage() { } else if (assigneeFilter === "unassigned") { filtered = filtered.filter((task) => !task.assignee_id); } else { - filtered = filtered.filter((task) => task.assignee_id === assigneeFilter); + filtered = filtered.filter( + (task) => task.assignee_id === assigneeFilter, + ); } } + // Search query + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + filtered = filtered.filter( + (task) => + task.title?.toLowerCase().includes(q) || + task.description?.toLowerCase().includes(q), + ); + } + return filtered; - }, [allTasks, selectedTabloId, statusFilter, assigneeFilter, user.id]); + }, [ + allTasks, + selectedTabloId, + statusFilter, + assigneeFilter, + user.id, + searchQuery, + ]); // Initialize Kanban columns from filtered tasks const columns = useMemo((): KanbanColumn[] => { @@ -178,7 +214,7 @@ export function TasksPage() { const handleDrop = ( e: React.DragEvent, - targetStatus: "todo" | "in_progress" | "in_review" | "done" + targetStatus: "todo" | "in_progress" | "in_review" | "done", ) => { e.preventDefault(); const taskId = e.dataTransfer.getData("taskId"); @@ -196,132 +232,177 @@ export function TasksPage() { }); }; + const viewTabs = [ + { id: "kanban" as const, label: "Tableau", icon: KanbanIcon }, + { id: "aggregated" as const, label: "Liste", icon: ListIcon }, + { + id: "gantt" as const, + label: "Roadmap", + icon: MapIcon, + disabled: true, + comingSoon: true, + }, + { + id: "calendar" as const, + label: "Calendrier", + icon: CalendarIcon, + disabled: true, + comingSoon: true, + }, + ]; + return (
{/* Header */} -
-
-
- {t("pages:tasks.title")} - {t("pages:tasks.subtitle")} -
- - {/* Filters */} -
-
- {/* Tablo Filter */} -
- -
- - {/* Status Filter */} -
- -
- - {/* Assignee Filter */} -
- -
-
- -
- {/* View Mode Toggle */} -
- - -
- - {/* Add Task Button */} - -
-
+
+ {/* Title row */} +
+

+ {t("pages:tasks.title")} +

+
-
+ + {/* View tabs */} +
+ {viewTabs.map((tab) => { + const isActive = viewMode === tab.id; + return ( + + ); + })} +
+ + {/* Search + filter row */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
+ + + + + + + Projet + + setSelectedTabloId("all")} + > + Tous les projets + + {tablos?.map((tablo) => ( + setSelectedTabloId(tablo.id)} + > +
+
+ {tablo.name} +
+ + ))} + + Statut + + {Object.entries(statusLabels).map(([value, label]) => ( + setStatusFilter(value as TaskStatus)} + > + {label} + + ))} + + Assigné + + setAssigneeFilter("all")} + > + Tous + + setAssigneeFilter("me")} + > + Assigné à moi + + setAssigneeFilter("unassigned")} + > + Non assigné + + {assignees.map((assignee) => ( + setAssigneeFilter(assignee.id)} + > + {assignee.name} + + ))} + + +
+
{/* Main Content */} -
+
{viewMode === "kanban" ? ( /* Kanban Board */ -
+ <> {tablosLoading || tasksLoading ? (
@@ -340,105 +421,192 @@ export function TasksPage() {
) : (
- {columns.map((column) => ( -
- {/* Column Header */} -
-
-

{column.title}

- - {column.tasks.length} - -
-
+ {columns.map((column) => { + const columnIconColor = + { + todo: "text-gray-400", + in_progress: "text-yellow-500", + in_review: "text-blue-500", + done: "text-green-500", + }[column.status] ?? "text-gray-400"; - {/* Tasks */} + return (
handleDrop(e, column.status)} + key={column.id} + className="w-full h-fit bg-[#F9FAFB] dark:bg-gray-800/60 rounded-[12px] p-4" > - {column.tasks.length === 0 ? ( -
- {t("pages:tasks.emptyState.noTasks")} + {/* Column header */} +
+
+ +

+ {column.title} +

+ + {column.tasks.length} +
- ) : ( - column.tasks.map((task) => { - const taskWithTablo = task as TaskWithTablo; - return ( -
handleDragStart(e, task)} - className="cursor-pointer" - onClick={() => handleTaskClick(task)} - > -
-

- {task.title} -

+ +
- {task.description && ( -

- {task.description} -

+ {/* Task cards */} +
handleDrop(e, column.status)} + > + {column.tasks.length === 0 ? ( +
+ {t("pages:tasks.emptyState.noTasks")} +
+ ) : ( + column.tasks.map((task) => { + const taskWithTablo = task as TaskWithTablo; + // const formattedDate = task.due_date + // ? new Intl.DateTimeFormat("en-US", { + // month: "short", + // day: "2-digit", + // year: "numeric", + // }).format(new Date(task.due_date)) + // : null; + + return ( +
handleDragStart(e, task)} + onClick={() => handleTaskClick(task)} + className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-3 shadow-sm hover:shadow-md transition-shadow border border-gray-100 dark:border-gray-700 cursor-pointer" + > + {/* Title + kebab */} +
+

+ {task.title} +

+ + + + + + { e.stopPropagation(); handleTaskClick(task); }}> + Ouvrir la tâche + + + Déplacer vers + {(["todo", "in_progress", "in_review", "done"] as const) + .filter((s) => s !== task.status) + .map((s) => ( + { + e.stopPropagation(); + updateTaskMutation.mutate({ id: task.id, status: s }); + }} + > + {columnTitles[s]} + + ))} + + +
+ + {/* Due date — commented out until field is available + {formattedDate && ( +
+ + {formattedDate} +
)} + */} - {/* Tablo Badge */} + {/* Tablo row */} {taskWithTablo.tablos && ( -
- +
+ + {taskWithTablo.tablos.name + .charAt(0) + .toUpperCase()} + +
+ {taskWithTablo.tablos.name}
)} - {/* Assignee */} -
-
+ {/* Footer: stats + assignee */} +
+
+
+ + 0 +
+
+ + 0 +
+
+ + {/* Assignee avatar */} +
{task.assignee_id ? ( -
- {task.assignee_avatar ? ( - {task.assignee_name - ) : ( -
- {task.assignee_name?.charAt(0).toUpperCase() || ( - - )} -
- )} -
+ task.assignee_avatar ? ( + {task.assignee_name + ) : ( +
+ {task.assignee_name + ?.charAt(0) + .toUpperCase() || ( + + )} +
+ ) ) : ( -
- +
+
)}
-
- ); - }) - )} + ); + }) + )} +
-
- ))} + ); + })}
)} -
+ ) : ( - /* Aggregated View by Tablo - Table */ -
+ /* List View — grouped by status column */ + <> {tablosLoading || tasksLoading ? (
@@ -456,147 +624,166 @@ export function TasksPage() {

) : ( -
- - - - - - - - - - - - {Array.from(tasksByTablo.entries()).map(([tabloId, tasks], tabloIndex) => { - const tablo = tasks[0]?.tablos; - return tasks.map((task, index) => { - const getStatusBadge = (status: string) => { - const statusConfig = { - todo: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200", - in_progress: - "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", - in_review: - "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", - done: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", - }; +
+ {columns.map((column) => { + if (column.tasks.length === 0) return null; + const columnIconColor = { + todo: "text-gray-400", + in_progress: "text-yellow-500", + in_review: "text-blue-500", + done: "text-green-500", + }[column.status] ?? "text-gray-400"; - return ( - - {statusLabels[status as TaskStatus] || status} - - ); - }; + return ( +
+ {/* Column header */} +
+
+ +

{column.title}

+ + {column.tasks.length} + +
+ +
- const isFirstRowOfTablo = index === 0; - const isLastRowOfTablo = index === tasks.length - 1; - const isLastTablo = tabloIndex === tasksByTablo.size - 1; - - return ( -
handleTaskClick(task)} - > - {/* Tablo Column - only show on first row of each tablo group */} - + + {/* Assignee */} + + + {/* Kebab */} + + + ); + })} + +
- Tablo - - Tâche - - Statut - - Assigné - - Description -
- {isFirstRowOfTablo && ( -
- {tablo && ( - <> -
- - {tablo.name} + {/* Table */} +
+ + + + + + + + + + + + + + + + {column.tasks.map((task) => { + const taskWithTablo = task as TaskWithTablo; + return ( + handleTaskClick(task)} + > + {/* Task name */} + - {/* Task Title */} - - {/* Status */} - - {/* Assignee */} - + + {/* Project */} + - {/* Description */} - - - ); - }); - })} - -
TÂCHEPROJETPERSONNES +
+
+ {column.status === "done" ? ( + + ) : ( +
+ )} + + {task.title} - - )} - {!tablo && ( - - Tablo inconnu - - )} -
- )} -
-
- {task.title} -
-
- {getStatusBadge(task.status || "todo")} - -
- {task.assignee_id ? ( - <> - {task.assignee_avatar ? ( - {task.assignee_name - ) : ( -
- {task.assignee_name?.charAt(0).toUpperCase() || ( - - )} +
+
+ {taskWithTablo.tablos ? ( +
+
+ {taskWithTablo.tablos.name.charAt(0).toUpperCase()} +
+ {taskWithTablo.tablos.name}
+ ) : ( + )} - - {task.assignee_name} - - - ) : ( - - {t("pages:tasks.unassigned")} - - )} - -
-
- {task.description || "-"} -
-
+
+
+ {task.assignee_id ? ( + task.assignee_avatar ? ( + {task.assignee_name + ) : ( +
+ {task.assignee_name?.charAt(0).toUpperCase() || } +
+ ) + ) : ( +
+ +
+ )} +
+
+ + + + + + { e.stopPropagation(); handleTaskClick(task); }}> + Ouvrir la tâche + + + Déplacer vers + {(["todo", "in_progress", "in_review", "done"] as const) + .filter((s) => s !== task.status) + .map((s) => ( + { + e.stopPropagation(); + updateTaskMutation.mutate({ id: task.id, status: s }); + }} + > + {columnTitles[s]} + + ))} + + +
+
+
+ ); + })}
)} -
+ )}