Redesign tasks page and add files page

- Rework tasks page with new board/list views, header with view tabs (Tableau, Liste, Roadmap, Calendrier), search and filter dropdown
- Board view: new card style with tablo row, assignee avatar, kebab menu (ouvrir/déplacer)
- List view: grouped by status, table with fixed column layout, kebab actions
- Add files page (/fichiers) with per-project file tables, folder cards, upload modal, download/delete actions
- Add /all-filenames API endpoint to fetch all tablo file names in a single call
- Add files nav item and recent projects section in NavigationBar
- Translate all UI strings to French

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-02-21 18:05:32 +01:00
parent 26459459b4
commit 515fee98cd
No known key found for this signature in database
8 changed files with 1146 additions and 347 deletions

View file

@ -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);

View file

@ -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 (
<div className="px-2 pb-2">
<Separator className="border-gray-300/20 mb-3" />
<div className="px-2 mb-2">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
{t("projects", "Projects")}
</span>
</div>
<ul className="space-y-0.5">
{recentTablos.map((tablo) => {
const isActive = location.pathname === `/tablos/${tablo.id}`;
return (
<li key={tablo.id}>
<RouterLink
to={`/tablos/${tablo.id}`}
className={twMerge(
"flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors",
isActive
? "bg-navbar-darker text-white font-semibold"
: "text-gray-300/90 hover:bg-navbar-darker hover:text-white"
)}
>
<span
className="w-5 h-5 rounded-md shrink-0 flex items-center justify-center text-xs font-bold text-white"
style={{ backgroundColor: tablo.color ?? "#6b7280" }}
>
{tablo.name.charAt(0).toUpperCase()}
</span>
<span className="truncate flex-1">{tablo.name}</span>
</RouterLink>
</li>
);
})}
</ul>
</div>
);
}
export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
const location = useLocation();
const isReadOnly = useIsReadOnlyUser();
@ -370,6 +418,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("discussions"),
icon: <MessageCircleIcon className="w-5 h-5" />,
},
{
path: "/files",
label: t("files", "Fichiers"),
icon: <FolderIcon className="w-5 h-5" />,
},
// Notes feature temporarily hidden
// {
// path: "/notes",
@ -418,6 +471,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
) : null;
})}
</ul>
{/* Recent projects section */}
{!isCollapsed && <RecentProjectsSection />}
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
{/* Trial upsell message */}
{shouldShowTrialUpsell && !isCollapsed && (
@ -501,7 +558,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
Plan Freemium
</p>
<p className="text-xs mt-0.5 text-blue-700 dark:text-blue-300">
Passer au plan Starter pour profiter de tablos illimités.
Passer au plan Starter pour profiter de projets illimités.
</p>
</div>
</div>
@ -573,9 +630,9 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
</div>
</RouterLink>
</NavLink>
<li className="my-2">
<Separator className="border-gray-300/20" />
</li>
</li>
<li className="my-2">
<Separator className="border-gray-300/20" />
</li>
</ul>
</nav>

View file

@ -31,6 +31,25 @@ export const toastOptions = {
timeout: toastTimeout,
};
export interface AllTablosFileNames {
tablos: { tabloId: string; fileNames: string[] }[];
}
// Hook to get file names for all tablos in a single request
export function useAllTablosFileNames() {
const api = useAuthedApi();
return useQuery<AllTablosFileNames>({
queryKey: ["all-tablo-files"],
queryFn: async () => {
const response = await api.get("/api/v1/tablo-data/all-filenames");
if (response.status !== 200) {
throw new Error("Failed to fetch all tablo files");
}
return response.data;
},
});
}
// Hook to get all file names for a tablo
export function useTabloFileNames(tabloId: string) {
const api = useAuthedApi();

View file

@ -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: <TasksPage />,
},
{
path: "files",
element: <FilesPage />,
},
{
path: "tablos",
element: <TablosPage />,

View file

@ -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",

View file

@ -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",

View file

@ -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<string>(tablos[0]?.id ?? "");
const [selectedFolderId, setSelectedFolderId] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: foldersData } = useTabloFolders(selectedTabloId);
const folders = foldersData?.folders ?? [];
const createFile = useCreateTabloFile();
const handleTabloChange = (tabloId: string) => {
setSelectedTabloId(tabloId);
setSelectedFolderId("");
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !selectedTabloId) return;
const maxSize = 20 * 1024 * 1024;
if (file.size > maxSize) {
toast.add(
{ title: "Erreur", description: "Le fichier ne peut pas dépasser 20MB", type: "error" },
{ timeout: 5000 }
);
return;
}
setIsUploading(true);
try {
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (ev) => resolve(ev.target?.result as string);
reader.onerror = reject;
if (file.type.startsWith("text/") || file.type === "application/json") {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
});
const fileName = selectedFolderId
? `${getFolderFilePrefix(selectedFolderId)}${file.name}`
: file.name;
await createFile.mutateAsync({
tabloId: selectedTabloId,
fileName,
data: { content, contentType: file.type || "application/octet-stream" },
});
onClose();
} catch {
// error handled by hook
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload a file</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Project selector */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<div className="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
{tablos.map((tablo) => (
<button
key={tablo.id}
type="button"
onClick={() => handleTabloChange(tablo.id)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
selectedTabloId === tablo.id
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<span
className={`w-6 h-6 rounded-md shrink-0 flex items-center justify-center text-xs font-bold text-white ${tablo.color ?? "bg-gray-500"}`}
>
{tablo.name.charAt(0).toUpperCase()}
</span>
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
</button>
))}
</div>
</div>
{/* Folder selector (optional) */}
{folders.length > 0 && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Folder (optional)</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setSelectedFolderId("")}
className={`px-3 py-1.5 rounded-lg border text-sm transition-colors ${
selectedFolderId === ""
? "border-primary bg-primary/5 dark:bg-primary/10 text-primary"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
No folder
</button>
{folders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => setSelectedFolderId(folder.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm transition-colors ${
selectedFolderId === folder.id
? "border-primary bg-primary/5 dark:bg-primary/10 text-primary"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<FolderIcon className="w-3.5 h-3.5" />
{folder.name}
</button>
))}
</div>
</div>
)}
{/* File picker */}
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileSelect} />
<Button
className="w-full gap-2"
onClick={() => fileInputRef.current?.click()}
disabled={!selectedTabloId || isUploading}
>
{isUploading ? (
<>
<LoadingSpinner />
Uploading...
</>
) : (
<>
<PlusIcon className="w-4 h-4" />
Choose file &amp; upload
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Folder cards grid ───────────────────────────────────────────────────────
function FolderGrid({
folders,
folderMap,
}: {
folders: { id: string; name: string; description?: string }[];
folderMap: Map<string, string[]>;
}) {
const visibleFolders = folders.filter((f) => (folderMap.get(f.id) ?? []).length > 0);
if (visibleFolders.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mb-5">
{visibleFolders.map((folder) => {
const count = folderMap.get(folder.id)?.length ?? 0;
return (
<div
key={folder.id}
className="bg-white dark:bg-gray-800 rounded-xl p-4 border border-[#F2F4F7] dark:border-gray-700 hover:shadow-sm transition-all"
>
<div className="flex flex-col items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center">
<FolderIcon className="w-5 h-5 text-amber-500 dark:text-amber-400" />
</div>
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight truncate">
{folder.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{count} file{count !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}
// ─── Per-project section ─────────────────────────────────────────────────────
function TabloFilesSection({
tabloId,
tabloName,
tabloColor,
fileNames,
}: {
tabloId: string;
tabloName: string;
tabloColor: string | null;
fileNames: string[];
}) {
const { data: foldersData } = useTabloFolders(tabloId);
const { mutate: downloadFile } = useDownloadTabloFile();
const { mutate: deleteFile } = useDeleteTabloFile();
const allFileNames = fileNames.filter((f) => !f.startsWith("."));
const folders = foldersData?.folders ?? [];
const folderMap = new Map<string, string[]>();
const rootFiles: string[] = [];
for (const fileName of allFileNames) {
const folderId = extractFolderIdFromFileName(fileName);
if (folderId) {
if (!folderMap.has(folderId)) folderMap.set(folderId, []);
folderMap.get(folderId)!.push(fileName);
} else {
rootFiles.push(fileName);
}
}
if (allFileNames.length === 0) return null;
return (
<div className="mb-10">
{/* Project header */}
<div className="flex items-center gap-3 mb-4">
<span
className={`w-7 h-7 rounded-lg shrink-0 flex items-center justify-center text-sm font-bold text-white ${tabloColor ?? "bg-gray-500"}`}
>
{tabloName.charAt(0).toUpperCase()}
</span>
<Link
to={`/tablos/${tabloId}`}
className="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline"
>
{tabloName}
</Link>
<span className="text-sm text-gray-400 dark:text-gray-500">
{allFileNames.length} file{allFileNames.length !== 1 ? "s" : ""}
</span>
</div>
{/* Folder cards */}
<FolderGrid folders={folders} folderMap={folderMap} />
{/* Files per folder */}
{folders.map((folder) => {
const folderFiles = folderMap.get(folder.id) ?? [];
if (folderFiles.length === 0) return null;
return (
<div key={folder.id} className="mb-4">
<div className="flex items-center gap-2 mb-2 px-1">
<FolderIcon className="w-4 h-4 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{folder.name}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">({folderFiles.length})</span>
</div>
<FileTable
fileNames={folderFiles}
onDownload={(fileName) => downloadFile({ tabloId, fileName })}
onDelete={(fileName) => deleteFile({ tabloId, fileName })}
/>
</div>
);
})}
{/* Root files */}
{rootFiles.length > 0 && (
<>
{folders.length > 0 && (
<div className="flex items-center gap-2 mb-2 px-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Other files</span>
<span className="text-xs text-gray-400 dark:text-gray-500">({rootFiles.length})</span>
</div>
)}
<FileTable
fileNames={rootFiles}
onDownload={(fileName) => downloadFile({ tabloId, fileName })}
onDelete={(fileName) => deleteFile({ tabloId, fileName })}
/>
</>
)}
</div>
);
}
// ─── File table ───────────────────────────────────────────────────────────────
function FileTable({
fileNames,
onDownload,
onDelete,
}: {
fileNames: string[];
onDownload: (fileName: string) => void;
onDelete: (fileName: string) => void;
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-100 dark:border-gray-700 overflow-hidden mb-4">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-y border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<tr>
<th className="px-6 py-3 text-left text-sm font-normal text-gray-900 dark:text-gray-300">File name</th>
<th className="px-6 py-3 w-12" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{fileNames.map((fileName) => {
const displayName = getFileNameWithoutFolder(fileName);
const iconColor = getFileIconColor(displayName);
return (
<tr key={fileName} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 ${iconColor} rounded-lg flex items-center justify-center flex-shrink-0`}
>
<FileTextIcon className="w-5 h-5 text-white" />
</div>
<p className="text-sm font-normal text-gray-900 dark:text-gray-100">{displayName}</p>
</div>
</td>
<td className="px-6 py-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors opacity-0 group-hover:opacity-100"
aria-label={`Actions for ${displayName}`}
>
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDownload(fileName)}>
<DownloadIcon className="w-4 h-4 mr-2" />
Download
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(fileName)}
className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400"
>
<Trash2Icon className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function FilesPage() {
const { t } = useTranslation("navigation");
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: allFiles, isLoading: filesLoading } = useAllTablosFileNames();
const [uploadOpen, setUploadOpen] = useState(false);
const isLoading = tablosLoading || filesLoading;
const filesByTabloId = new Map<string, string[]>(
(allFiles?.tablos ?? []).map(({ tabloId, fileNames }) => [tabloId, fileNames])
);
const tablosWithFiles = (tablos ?? []).filter((tablo) => {
const files = filesByTabloId.get(tablo.id) ?? [];
return files.some((f) => !f.startsWith("."));
});
return (
<div className="py-6 px-6">
{/* Header */}
<div className="flex items-center justify-between pb-6">
<h1 className="text-3xl font-semibold text-gray-900 dark:text-gray-100">{t("files", "Files")}</h1>
<Button
className="gap-2 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
onClick={() => setUploadOpen(true)}
disabled={!tablos || tablos.length === 0}
>
<PlusIcon className="w-5 h-5" />
Upload
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-24">
<LoadingSpinner />
</div>
) : tablosWithFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<LayersIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400">No files found.</p>
</div>
) : (
<div>
{tablosWithFiles.map((tablo) => (
<TabloFilesSection
key={tablo.id}
tabloId={tablo.id}
tabloName={tablo.name}
tabloColor={tablo.color}
fileNames={filesByTabloId.get(tablo.id) ?? []}
/>
))}
</div>
)}
{tablos && tablos.length > 0 && (
<UploadModal
isOpen={uploadOpen}
onClose={() => setUploadOpen(false)}
tablos={tablos}
/>
)}
</div>
);
}

View file

@ -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<TaskStatus>("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("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 (
<div className="min-h-screen">
{/* Header */}
<header className="bg-card shadow-sm border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="mb-6">
<TypographyH3>{t("pages:tasks.title")}</TypographyH3>
<TypographyMuted>{t("pages:tasks.subtitle")}</TypographyMuted>
</div>
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center flex-1">
{/* Tablo Filter */}
<div className="w-full lg:w-64">
<Select
value={selectedTabloId}
onValueChange={(value) => setSelectedTabloId(value)}
>
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
<SelectValue placeholder="Tous les tablos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("pages:tasks.filters.allTablos")}</SelectItem>
{tablos?.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
<div className="flex items-center gap-2">
<div
className={twMerge(
"w-2 h-2 rounded-full",
tablo.color || "bg-muted-foreground"
)}
/>
{tablo.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<div className="w-full lg:w-48">
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as TaskStatus)}
>
<SelectTrigger className="w-full h-10" aria-label="Filtrer par statut">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(statusLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Assignee Filter */}
<div className="w-full lg:w-48">
<Select value={assigneeFilter} onValueChange={(value) => setAssigneeFilter(value)}>
<SelectTrigger className="w-full h-10" aria-label="Filtrer par assigné">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("pages:tasks.filters.allAssignees")}</SelectItem>
<SelectItem value="me">{t("pages:tasks.filters.assignedToMe")}</SelectItem>
<SelectItem value="unassigned">
{t("pages:tasks.filters.unassigned")}
</SelectItem>
{assignees.map((assignee) => (
<SelectItem key={assignee.id} value={assignee.id}>
{assignee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
{/* View Mode Toggle */}
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 border border-border">
<button
onClick={() => setViewMode("kanban")}
className={`p-1.5 rounded transition-colors ${
viewMode === "kanban"
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title={t("pages:tasks.view.kanban")}
aria-label={t("pages:tasks.view.kanban")}
>
<Kanban className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode("aggregated")}
className={`p-1.5 rounded transition-colors ${
viewMode === "aggregated"
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title={t("pages:tasks.view.aggregated")}
aria-label={t("pages:tasks.view.aggregated")}
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
{/* Add Task Button */}
<Button onClick={() => setIsTaskModalOpen(true)} size="sm" className="gap-2">
<PlusIcon className="w-4 h-4" />
{t("pages:tasks.createTask")}
</Button>
</div>
</div>
<div className="px-4 md:px-6 pt-6 md:pt-10 pb-5">
{/* Title row */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100">
{t("pages:tasks.title")}
</h1>
<Button
onClick={() => setIsTaskModalOpen(true)}
className="bg-purple-600 hover:bg-purple-700 text-white w-full md:w-auto gap-2"
>
<PlusIcon className="w-4 h-4" />
{t("pages:tasks.createTask")}
</Button>
</div>
</header>
{/* View tabs */}
<div className="flex flex-wrap items-center gap-2 md:gap-6 mb-4 border-b border-[#EAECF0] dark:border-gray-700">
{viewTabs.map((tab) => {
const isActive = viewMode === tab.id;
return (
<button
key={tab.id}
type="button"
disabled={tab.disabled}
onClick={() =>
!tab.disabled &&
setViewMode(tab.id as "kanban" | "aggregated")
}
className={twMerge(
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
isActive
? "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400"
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
tab.disabled && "cursor-not-allowed",
)}
>
<tab.icon className={twMerge("w-4 h-4", tab.disabled && "opacity-40")} />
<span className={tab.disabled ? "opacity-40" : ""}>{tab.label}</span>
{"comingSoon" in tab && tab.comingSoon && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none">
Bientôt
</span>
)}
</button>
);
})}
</div>
{/* Search + filter row */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="flex-1 relative md:max-w-[300px] w-full">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full md:w-auto gap-2 bg-transparent"
>
<Settings2Icon className="w-4 h-4" />
Filtrer
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Projet</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={selectedTabloId === "all"}
onCheckedChange={() => setSelectedTabloId("all")}
>
Tous les projets
</DropdownMenuCheckboxItem>
{tablos?.map((tablo) => (
<DropdownMenuCheckboxItem
key={tablo.id}
checked={selectedTabloId === tablo.id}
onCheckedChange={() => setSelectedTabloId(tablo.id)}
>
<div className="flex items-center gap-2">
<div
className={twMerge(
"w-2 h-2 rounded-full shrink-0",
tablo.color || "bg-gray-400",
)}
/>
{tablo.name}
</div>
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Statut</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(statusLabels).map(([value, label]) => (
<DropdownMenuCheckboxItem
key={value}
checked={statusFilter === value}
onCheckedChange={() => setStatusFilter(value as TaskStatus)}
>
{label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Assigné</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={assigneeFilter === "all"}
onCheckedChange={() => setAssigneeFilter("all")}
>
Tous
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={assigneeFilter === "me"}
onCheckedChange={() => setAssigneeFilter("me")}
>
Assigné à moi
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={assigneeFilter === "unassigned"}
onCheckedChange={() => setAssigneeFilter("unassigned")}
>
Non assigné
</DropdownMenuCheckboxItem>
{assignees.map((assignee) => (
<DropdownMenuCheckboxItem
key={assignee.id}
checked={assigneeFilter === assignee.id}
onCheckedChange={() => setAssigneeFilter(assignee.id)}
>
{assignee.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<main className="px-4 md:px-6 pb-6">
{viewMode === "kanban" ? (
/* Kanban Board */
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
<>
{tablosLoading || tasksLoading ? (
<div className="flex items-center justify-center h-64">
<LoadingSpinner />
@ -340,105 +421,192 @@ export function TasksPage() {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{columns.map((column) => (
<div key={column.id} className="flex flex-col bg-muted/30 rounded-lg p-3">
{/* Column Header */}
<div className="flex items-center justify-between mb-3 pb-2 border-b border-border">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{column.title}</h3>
<span className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground font-medium">
{column.tasks.length}
</span>
</div>
</div>
{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 (
<div
className="flex-1 space-y-2 overflow-y-auto min-h-[200px]"
onDragOver={handleDragOver}
onDrop={(e) => 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 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
{t("pages:tasks.emptyState.noTasks")}
{/* Column header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<CircleIcon
className={`w-5 h-5 ${columnIconColor}`}
/>
<h2 className="font-semibold text-gray-800 dark:text-gray-100">
{column.title}
</h2>
<span className="bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-medium px-2 py-0.5 rounded-full">
{column.tasks.length}
</span>
</div>
) : (
column.tasks.map((task) => {
const taskWithTablo = task as TaskWithTablo;
return (
<div
key={task.id}
draggable
onDragStart={(e) => handleDragStart(e, task)}
className="cursor-pointer"
onClick={() => handleTaskClick(task)}
>
<div className="bg-card border border-border rounded-lg p-3 hover:shadow-md transition-shadow">
<h4 className="font-medium text-foreground mb-1 line-clamp-2">
{task.title}
</h4>
<button
type="button"
onClick={() => setIsTaskModalOpen(true)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-1 transition-colors"
>
<PlusIcon className="w-[18px] h-[18px]" />
</button>
</div>
{task.description && (
<p className="text-muted-foreground text-sm line-clamp-2 mt-1 mb-2">
{task.description}
</p>
{/* Task cards */}
<div
className="space-y-3 pr-1 min-h-[80px]"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, column.status)}
>
{column.tasks.length === 0 ? (
<div className="flex items-center justify-center h-20 text-gray-400 dark:text-gray-500 text-sm">
{t("pages:tasks.emptyState.noTasks")}
</div>
) : (
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 (
<div
key={task.id}
draggable
onDragStart={(e) => 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 */}
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight line-clamp-2 flex-1">
{task.title}
</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 mt-0.5"
>
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
Ouvrir la tâche
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
{(["todo", "in_progress", "in_review", "done"] as const)
.filter((s) => s !== task.status)
.map((s) => (
<DropdownMenuItem
key={s}
onClick={(e) => {
e.stopPropagation();
updateTaskMutation.mutate({ id: task.id, status: s });
}}
>
{columnTitles[s]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Due date commented out until field is available
{formattedDate && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mb-3">
<CalendarIcon className="w-3.5 h-3.5 mr-1.5" />
{formattedDate}
</div>
)}
*/}
{/* Tablo Badge */}
{/* Tablo row */}
{taskWithTablo.tablos && (
<div className="mb-2">
<span
<div className="flex items-center mb-3 border-b border-dashed border-[#D0D5DD] dark:border-gray-600 pb-3">
<div
className={twMerge(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
taskWithTablo.tablos.color,
getTextColorFromTabloColor(taskWithTablo.tablos.color || "")
"w-5 h-5 rounded-[5px] p-1 mr-2 flex items-center justify-center shrink-0",
taskWithTablo.tablos.color ||
"bg-gray-400",
)}
>
<span className="text-[8px] font-bold text-white leading-none">
{taskWithTablo.tablos.name
.charAt(0)
.toUpperCase()}
</span>
</div>
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
{taskWithTablo.tablos.name}
</span>
</div>
)}
{/* Assignee */}
<div className="flex items-center justify-end mt-2">
<div className="flex items-center gap-2">
{/* Footer: stats + assignee */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3 text-gray-500 dark:text-gray-400">
<div className="flex items-center text-xs">
<MessageSquareIcon className="w-3.5 h-3.5 mr-1" />
0
</div>
<div className="flex items-center text-xs">
<PaperclipIcon className="w-3.5 h-3.5 mr-1" />
0
</div>
</div>
{/* Assignee avatar */}
<div className="flex -space-x-2">
{task.assignee_id ? (
<div className="flex items-center gap-1">
{task.assignee_avatar ? (
<img
src={task.assignee_avatar}
alt={task.assignee_name || "Assignee"}
className="w-6 h-6 rounded-full border border-border"
/>
) : (
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xs font-medium border border-border">
{task.assignee_name?.charAt(0).toUpperCase() || (
<UserIcon className="w-3 h-3" />
)}
</div>
)}
</div>
task.assignee_avatar ? (
<img
src={task.assignee_avatar}
alt={task.assignee_name || "Assignee"}
className="w-6 h-6 rounded-full border-2 border-white dark:border-gray-800 object-cover"
/>
) : (
<div className="w-6 h-6 rounded-full bg-purple-500 border-2 border-white dark:border-gray-800 flex items-center justify-center text-white text-[10px] font-medium">
{task.assignee_name
?.charAt(0)
.toUpperCase() || (
<UserIcon className="w-3 h-3" />
)}
</div>
)
) : (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-muted-foreground border border-border">
<UserIcon className="w-3 h-3" />
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-600 border-2 border-white dark:border-gray-800 flex items-center justify-center">
<UserIcon className="w-3 h-3 text-gray-400 dark:text-gray-300" />
</div>
)}
</div>
</div>
</div>
</div>
);
})
)}
);
})
)}
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>
</>
) : (
/* Aggregated View by Tablo - Table */
<div className="bg-card rounded-lg shadow-sm border border-border overflow-hidden">
/* List View — grouped by status column */
<>
{tablosLoading || tasksLoading ? (
<div className="flex items-center justify-center h-64">
<LoadingSpinner />
@ -456,147 +624,166 @@ export function TasksPage() {
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tablo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tâche
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Assigné
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</th>
</tr>
</thead>
<tbody>
{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",
};
<div className="space-y-6">
{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 (
<span
className={twMerge(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
statusConfig[status as keyof typeof statusConfig] ||
statusConfig.todo
)}
>
{statusLabels[status as TaskStatus] || status}
</span>
);
};
return (
<div key={column.id} className="bg-[#F9FAFB] dark:bg-gray-800/60 rounded-lg p-2">
{/* Column header */}
<div className="px-4 md:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<CircleIcon className={`w-5 h-5 ${columnIconColor}`} />
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{column.title}</h3>
<span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded-full">
{column.tasks.length}
</span>
</div>
<button
type="button"
onClick={() => setIsTaskModalOpen(true)}
className="inline-flex items-center gap-1 h-8 px-3 text-xs font-medium rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<PlusIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
const isFirstRowOfTablo = index === 0;
const isLastRowOfTablo = index === tasks.length - 1;
const isLastTablo = tabloIndex === tasksByTablo.size - 1;
return (
<tr
key={`${tabloId}-${task.id}`}
className={twMerge(
"hover:bg-muted/30 cursor-pointer transition-colors",
isFirstRowOfTablo && "border-t-2 border-border",
isLastRowOfTablo && !isLastTablo && "border-b-2 border-border"
)}
onClick={() => handleTaskClick(task)}
>
{/* Tablo Column - only show on first row of each tablo group */}
<td className="px-6 py-4 whitespace-nowrap">
{isFirstRowOfTablo && (
<div className="flex items-center gap-2">
{tablo && (
<>
<div
className={twMerge(
"w-3 h-3 rounded-full",
tablo.color || "bg-muted-foreground"
)}
/>
<span className="text-sm font-medium text-foreground">
{tablo.name}
{/* Table */}
<div className="overflow-x-auto bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
<table className="w-full table-fixed min-w-[600px]">
<colgroup>
<col className="w-[45%]" />
<col className="w-[35%]" />
<col className="w-[12%]" />
<col className="w-[8%]" />
</colgroup>
<thead>
<tr>
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">TÂCHE</th>
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">PROJET</th>
<th className="px-4 md:px-6 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-400">PERSONNES</th>
<th className="px-4 md:px-6 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-400" />
</tr>
</thead>
<tbody>
{column.tasks.map((task) => {
const taskWithTablo = task as TaskWithTablo;
return (
<tr
key={task.id}
className="border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
onClick={() => handleTaskClick(task)}
>
{/* Task name */}
<td className="px-4 md:px-6 py-3">
<div className="flex items-center gap-2">
{column.status === "done" ? (
<CircleCheckIcon className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 border-2 border-gray-300 dark:border-gray-600 rounded-full shrink-0" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{task.title}
</span>
</>
)}
{!tablo && (
<span className="text-sm font-medium text-muted-foreground">
Tablo inconnu
</span>
)}
</div>
)}
</td>
{/* Task Title */}
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">
{task.title}
</div>
</td>
{/* Status */}
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(task.status || "todo")}
</td>
{/* Assignee */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{task.assignee_id ? (
<>
{task.assignee_avatar ? (
<img
src={task.assignee_avatar}
alt={task.assignee_name || "Assignee"}
className="w-6 h-6 rounded-full border border-border"
/>
) : (
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xs font-medium border border-border">
{task.assignee_name?.charAt(0).toUpperCase() || (
<UserIcon className="w-3 h-3" />
)}
</div>
</td>
{/* Project */}
<td className="px-4 md:px-6 py-3">
{taskWithTablo.tablos ? (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className={twMerge(
"w-5 h-5 rounded-[4px] shrink-0 flex items-center justify-center text-[8px] font-bold text-white",
taskWithTablo.tablos.color || "bg-gray-400"
)}>
{taskWithTablo.tablos.name.charAt(0).toUpperCase()}
</div>
<span className="truncate">{taskWithTablo.tablos.name}</span>
</div>
) : (
<span className="text-sm text-gray-400"></span>
)}
<span className="text-sm text-foreground">
{task.assignee_name}
</span>
</>
) : (
<span className="text-sm text-muted-foreground">
{t("pages:tasks.unassigned")}
</span>
)}
</div>
</td>
{/* Description */}
<td className="px-6 py-4">
<div className="text-sm text-muted-foreground line-clamp-2 max-w-md">
{task.description || "-"}
</div>
</td>
</tr>
);
});
})}
</tbody>
</table>
</td>
{/* Assignee */}
<td className="px-4 md:px-6 py-3">
<div className="flex -space-x-1">
{task.assignee_id ? (
task.assignee_avatar ? (
<img
src={task.assignee_avatar}
alt={task.assignee_name || ""}
title={task.assignee_name || ""}
className="w-6 h-6 rounded-full border border-white dark:border-gray-800 object-cover"
/>
) : (
<div
title={task.assignee_name || ""}
className="w-6 h-6 rounded-full bg-purple-200 dark:bg-purple-900 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold border border-white dark:border-gray-800"
>
{task.assignee_name?.charAt(0).toUpperCase() || <UserIcon className="w-3 h-3" />}
</div>
)
) : (
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 border border-white dark:border-gray-800 flex items-center justify-center">
<UserIcon className="w-3 h-3 text-gray-400" />
</div>
)}
</div>
</td>
{/* Kebab */}
<td className="px-4 md:px-6 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<EllipsisVerticalIcon className="w-4 h-4 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
Ouvrir la tâche
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
{(["todo", "in_progress", "in_review", "done"] as const)
.filter((s) => s !== task.status)
.map((s) => (
<DropdownMenuItem
key={s}
onClick={(e) => {
e.stopPropagation();
updateTaskMutation.mutate({ id: task.id, status: s });
}}
>
{columnTitles[s]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
</main>