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:
parent
26459459b4
commit
515fee98cd
8 changed files with 1146 additions and 347 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
493
apps/main/src/pages/files.tsx
Normal file
493
apps/main/src/pages/files.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue