Merge pull request #55 from artslidd/develop

Big UI update
This commit is contained in:
Arthur Belleville 2025-12-04 23:37:37 +01:00 committed by GitHub
commit 7e4d544166
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1259 additions and 135 deletions

View file

@ -28,7 +28,7 @@ export const ImageColorPicker = ({
<div className="my-4 space-y-4">
{/* Mode Toggle */}
<div>
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-base font-semibold text-gray-800 dark:text-gray-300 mb-2">
Style
</label>
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
@ -48,7 +48,7 @@ export const ImageColorPicker = ({
className={`flex-1 px-4 py-2 text-sm font-medium ${
creationMode === "color"
? "bg-blue-600 text-white"
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
: "bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
} transition-colors`}
onClick={() => setCreationMode("color")}
>

View file

@ -19,8 +19,9 @@ import {
Circle,
ConstructionIcon,
CreditCard,
FileTextIcon,
// FileTextIcon, // Notes feature temporarily hidden
Kanban,
ListTodo,
LogOutIcon,
MessageCircleIcon,
MinusIcon,
@ -188,6 +189,14 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
/>
</RouterLink>
<RouterLink to="/planning?tab=events">
<MenuDropdownItem
icon={<CalendarCheckIcon className="w-5 h-5" aria-hidden="true" />}
label={t("myEvents")}
variant="default"
/>
</RouterLink>
<RouterLink to="/availabilities">
<MenuDropdownItem
icon={<CalendarIcon className="w-5 h-5" aria-hidden="true" />}
@ -328,13 +337,6 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("projects"),
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/events",
label: t("myEvents"),
icon: <CalendarCheckIcon className="w-5 h-5" />,
isDisabled: isReadOnly,
},
{
path: "/kanban",
label: t("kanban"),
@ -348,6 +350,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
isDisabled: true,
},
{ isHorizontalBar: true },
{
path: "/tasks",
label: t("tasks"),
icon: <ListTodo className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/planning",
label: t("planning"),
@ -358,11 +366,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("discussions"),
icon: <MessageCircleIcon className="w-5 h-5" />,
},
{
path: "/notes",
label: t("notes"),
icon: <FileTextIcon className="w-5 h-5" />,
},
// Notes feature temporarily hidden
// {
// path: "/notes",
// label: t("notes"),
// icon: <FileTextIcon className="w-5 h-5" />,
// },
];
return (
<nav className="flex flex-1 flex-col" aria-label="Primary navigation">

View file

@ -69,7 +69,7 @@ function getNotificationLink(notification: Notification): string {
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
return `/tablos/${metadata.tablo_id}`;
}
return "/events";
return "/planning?tab=events";
case "notes":
return `/notes/${entity_id}`;
case "tablo_access":

View file

@ -5,6 +5,7 @@ import { Channel as StreamChannel } from "stream-chat";
import { Channel, MessageInput, MessageList, useChatContext, Window } from "stream-chat-react";
import ChatProvider from "../providers/ChatProvider";
import { LoadingSpinner } from "./LoadingSpinner";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloDiscussionSectionProps {
tablo: UserTablo;
@ -47,12 +48,15 @@ const TabloChat = ({ tablo }: { tablo: UserTablo }) => {
);
};
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectionProps) => {
return (
<div className="flex flex-col h-full">
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>
<div className="flex-1 bg-card rounded-lg border border-border overflow-hidden min-h-0">

View file

@ -4,14 +4,18 @@ import { Calendar, Clock, Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useEventsByTablo } from "../hooks/events";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
import { TabloHeaderActions } from "./TabloHeaderActions";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { useTranslation } from "react-i18next";
interface TabloEventsSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
export const TabloEventsSection = ({ tablo }: TabloEventsSectionProps) => {
export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { data: events, isLoading, error } = useEventsByTablo(tablo.id);
const isReadOnly = useIsReadOnlyUser();
@ -31,7 +35,7 @@ export const TabloEventsSection = ({ tablo }: TabloEventsSectionProps) => {
});
const handleCreateEvent = () => {
navigate(`/events/create?tablo_id=${tablo.id}`);
navigate(`/planning/create?tablo_id=${tablo.id}`);
};
const formatDate = (dateStr: string) => {
@ -51,19 +55,23 @@ export const TabloEventsSection = ({ tablo }: TabloEventsSectionProps) => {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">Événements à venir</h1>
<p className="text-muted-foreground mt-1">Gérez les événements futurs de ce tablo</p>
<TypographyH3 className="text-3xl font-bold text-foreground">
{t("tablo:events.title")}
</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
{t("tablo:events.description")}
</TypographyMuted>
{!isReadOnly && (
<Button onClick={handleCreateEvent} className="flex items-center gap-2 mt-4">
<Plus className="w-4 h-4" />
{t("tablo:events.createEvent")}
</Button>
)}
</div>
{!isReadOnly && (
<Button onClick={handleCreateEvent} className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Créer un événement
</Button>
)}
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>
{/* Events List */}
<div className="bg-card rounded-lg border border-border">
{isLoading ? (

View file

@ -11,6 +11,7 @@ import {
useTabloFileNames,
} from "../hooks/tablo_data";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloFilesSectionProps {
tablo: UserTablo;
@ -154,11 +155,14 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
return (
<div className="space-y-6">
<div>
<TypographyH3 className="text-3xl font-bold text-foreground">Fichiers</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
Gérez les fichiers attachés à ce tablo
</TypographyMuted>
<div className="flex justify-between items-start">
<div>
<TypographyH3 className="text-3xl font-bold text-foreground">Fichiers</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
Gérez les fichiers attachés à ce tablo
</TypographyMuted>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>
{/* Error Banner */}

View file

@ -0,0 +1,322 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@xtablo/ui/components/dialog";
import { Input } from "@xtablo/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover";
import { Settings, Share2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
import { useInviteUser } from "../hooks/invite";
import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
import { useTabloMembers, useUpdateTablo } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
interface TabloHeaderActionsProps {
tablo: UserTablo;
isAdmin: boolean;
}
export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) => {
const { mutateAsync: updateTablo } = useUpdateTablo();
const currentUser = useUser();
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
// Settings state
const [editData, setEditData] = useState<UserTablo | null>(tablo);
const [isEditingName, setIsEditingName] = useState(false);
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedColor, setSelectedColor] = useState(tablo.color || "bg-blue-500");
const nameInputRef = useRef<HTMLInputElement>(null);
// Fetch members and invites for share dialog
const { data: members } = useTabloMembers(tablo?.id || "");
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || "");
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
useEffect(() => {
setEditData(tablo);
setSelectedColor(tablo.color || "bg-blue-500");
}, [tablo]);
// Auto-focus name input when editing
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [isEditingName]);
const handleSaveSettings = async () => {
if (editData && tablo) {
const updatedTablo: TabloUpdate & { id: string } = {
id: editData.id,
name: editData.name,
color: creationMode === "color" ? selectedColor : null,
};
try {
await updateTablo(updatedTablo);
toast.add(
{
title: "Tablo mis à jour",
description: "Les modifications ont été enregistrées",
type: "success",
},
{ timeout: 3000 }
);
} catch (_error) {
toast.add(
{
title: "Erreur",
description: "Impossible de mettre à jour le tablo",
type: "error",
},
{ timeout: 5000 }
);
}
}
};
const handleSendInvite = () => {
if (inviteEmail.trim() && tablo) {
inviteUser({ email: inviteEmail, tablo_id: tablo.id });
setInviteEmail("");
}
};
const isEmailValid = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const filteredMembers = members?.filter(
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
);
return (
<div className="inline-flex items-center gap-2">
{/* Member Avatars */}
{filteredMembers && filteredMembers.length > 0 && (
<div className="flex items-center -space-x-2 mr-2">
{filteredMembers.slice(0, 3).map((member) => {
const isCurrentUser = member.id === currentUser.id;
const avatarUrl = isCurrentUser ? currentUser.avatar_url : null;
return (
<Avatar
key={member.id}
className="w-7 h-7 border-2 border-background hover:z-10 transition-all cursor-pointer"
onClick={() => setIsShareDialogOpen(true)}
title={member.name}
>
{avatarUrl && <AvatarImage src={avatarUrl} alt={member.name} />}
<AvatarFallback className="bg-primary text-primary-foreground text-[10px] font-medium">
{member.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
{filteredMembers.length > 3 && (
<div
className="w-7 h-7 border-2 border-background rounded-full bg-muted flex items-center justify-center text-[10px] font-medium text-muted-foreground cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => setIsShareDialogOpen(true)}
title={`${filteredMembers.length - 3} autres membres`}
>
+{filteredMembers.length - 3}
</div>
)}
</div>
)}
{/* Share Button */}
{isAdmin && (
<Button variant="default" size="sm" onClick={() => setIsShareDialogOpen(true)}>
<Share2 className="w-4 h-4 mr-2" />
Partager
</Button>
)}
{/* Settings Popover */}
{isAdmin && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon">
<Settings className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Paramètres</h3>
{/* Name Edit */}
<div>
<label className="block text-base font-semibold text-gray-800 dark:text-gray-300 mb-2">
Nom du tablo
</label>
{isEditingName ? (
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
<Input
ref={nameInputRef}
type="text"
value={editData?.name}
onChange={(e) =>
setEditData((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setIsEditingName(false);
}
}}
placeholder="Nom du tablo"
/>
</ClickOutside>
) : (
<div
className="text-sm font-medium text-foreground cursor-text hover:text-primary hover:border-primary transition-colors border-2 border-dashed border-muted-foreground/30 rounded px-3 py-2"
onClick={() => setIsEditingName(true)}
>
{editData?.name}
</div>
)}
</div>
{/* Color/Image Picker */}
<div>
<ImageColorPicker
creationMode={creationMode}
setCreationMode={setCreationMode}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
</div>
{/* Save Button */}
<Button onClick={handleSaveSettings} className="w-full">
Sauvegarder
</Button>
</div>
</PopoverContent>
</Popover>
)}
{/* Share Dialog */}
<Dialog open={isShareDialogOpen} onOpenChange={setIsShareDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Partager le tablo</DialogTitle>
<DialogDescription>Invitez des personnes à collaborer sur ce tablo</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Invite Input */}
<div className="flex space-x-2">
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur"
className="flex-1"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center px-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
</div>
) : (
<Button
type="button"
onClick={handleSendInvite}
disabled={!isEmailValid(inviteEmail)}
>
Inviter
</Button>
)}
</div>
{/* Pending Invites */}
{pendingInvites && pendingInvites.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">
Invitations en attente ({pendingInvites.length})
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center space-x-2 p-2 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
>
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-xs">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-foreground truncate block">
{invite.invited_email}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Members List */}
{filteredMembers && filteredMembers.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">
Membres ({filteredMembers.length})
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{filteredMembers.map((member, index) => {
const isCurrentUser = member.id === currentUser.id;
const avatarUrl = isCurrentUser ? currentUser.avatar_url : null;
return (
<div
key={index}
className="flex items-center space-x-2 p-2 bg-muted rounded-lg"
>
<Avatar className="w-8 h-8">
{avatarUrl && <AvatarImage src={avatarUrl} alt={member.name} />}
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-medium">
{member.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-foreground truncate block">
{member.name}
</span>
<span className="text-xs text-muted-foreground">
{member.is_admin ? "Admin" : "Invité"}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
};

View file

@ -15,6 +15,7 @@ import {
useTasksByTablo,
useUpdateEtape,
} from "../hooks/tasks";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloOverviewSectionProps {
tablo: UserTablo;
@ -280,13 +281,16 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
return (
<div className="space-y-6">
<div>
<TypographyH3 className="text-3xl font-bold text-foreground">
{t("tablo:overview.title")}
</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
{t("tablo:overview.description")}
</TypographyMuted>
<div className="flex justify-between items-start">
<div>
<TypographyH3 className="text-3xl font-bold text-foreground">
{t("tablo:overview.title")}
</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
{t("tablo:overview.description")}
</TypographyMuted>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>
{!canManageEtapes && (

View file

@ -12,13 +12,15 @@ import {
} from "../hooks/tasks";
import { KanbanBoard } from "./kanban/KanbanBoard";
import { TaskModal } from "./kanban/TaskModal";
import { TabloHeaderActions } from "./TabloHeaderActions";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
interface TabloTasksSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => {
const { data: members = [] } = useTabloMembers(tablo.id);
const [columns, setColumns] = useState<KanbanColumn[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
@ -162,19 +164,22 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<TypographyH3 className="text-3xl font-bold text-foreground flex items-center gap-3">
<ListChecks className="w-8 h-8" />
Tâches
</h1>
<p className="text-muted-foreground mt-1">Gérez vos tâches avec un tableau Kanban</p>
</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
Gérez vos tâches avec un tableau Kanban
</TypographyMuted>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>
{/* Warning for orphaned tasks */}
{orphanedTasks.length > 0 && (
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900/50 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{orphanedTasks.length} {pluralize("tâche", orphanedTasks.length)} sans Étape

View file

@ -13,33 +13,54 @@ import { Textarea } from "@xtablo/ui/components/textarea";
import { TypographyH2 } from "@xtablo/ui/components/typography";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useCreateTask, useTask, useUpdateTask } from "../../hooks/tasks";
import { useCreateTask, useTask, useUpdateTask, useTabloEtapes } from "../../hooks/tasks";
import { useTabloMembers } from "../../hooks/tablos";
import type { TabloMember } from "./types";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
interface TaskModalProps {
isOpen: boolean;
tabloId: string;
taskId: string | undefined;
tabloId?: string; // Optional when creating a task - can select tablo
taskId?: string | undefined; // Optional - undefined when creating new task
onClose: () => void;
members: TabloMember[];
members?: TabloMember[]; // Optional - will be fetched if tabloId is provided
initialStatus?: TaskStatus;
etapes: Etape[];
etapes?: Etape[]; // Optional - will be fetched if tabloId is provided
tablos?: UserTablo[]; // Optional - for tablo selection when creating
allowTabloSelection?: boolean; // Whether to show tablo selector
}
export const TaskModal = ({
tabloId,
tabloId: initialTabloId,
taskId,
isOpen,
onClose,
members,
members: providedMembers,
initialStatus = "todo",
etapes,
etapes: providedEtapes,
tablos,
allowTabloSelection = false,
}: TaskModalProps) => {
const { data: task = null } = useTask(taskId);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
const [etapeId, setEtapeId] = useState<string>("none");
const [selectedTabloId, setSelectedTabloId] = useState<string>(
initialTabloId || tablos?.[0]?.id || ""
);
// Determine which tablo to use for fetching data
const tabloIdForFetch = allowTabloSelection ? selectedTabloId : initialTabloId || "";
// Fetch members and etapes for selected tablo if not provided
const { data: fetchedMembers = [] } = useTabloMembers(tabloIdForFetch || "");
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
// Use provided or fetched data
const members = providedMembers || fetchedMembers;
const etapes = providedEtapes || fetchedEtapes;
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
useEffect(() => {
if (task) {
@ -47,8 +68,20 @@ export const TaskModal = ({
setDescription(task.description ?? "");
setAssigneeId(task.assignee_id ?? "unassigned");
setEtapeId(task.parent_task_id ?? "none");
if (!initialTabloId && task.tablo_id) {
setSelectedTabloId(task.tablo_id);
}
} else {
// Reset form when creating new task
setTitle("");
setDescription("");
setAssigneeId("unassigned");
setEtapeId("none");
if (allowTabloSelection && tablos && tablos.length > 0) {
setSelectedTabloId(tablos[0].id);
}
}
}, [task]);
}, [task, initialTabloId, allowTabloSelection, tablos]);
const { mutate: createTask } = useCreateTask();
const { mutate: updateTask } = useUpdateTask();
@ -56,6 +89,7 @@ export const TaskModal = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
if (!currentTabloId) return; // Need a tablo to create task
if (taskId && task) {
updateTask({
@ -69,7 +103,7 @@ export const TaskModal = ({
});
} else {
createTask({
tablo_id: tabloId,
tablo_id: currentTabloId,
title: title.trim(),
description: description.trim(),
assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
@ -105,6 +139,30 @@ export const TaskModal = ({
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Tablo Selection - only show when creating and allowTabloSelection is true */}
{allowTabloSelection && !taskId && tablos && tablos.length > 0 && (
<div className="space-y-2">
<Label htmlFor="tablo">Tablo *</Label>
<Select value={selectedTabloId} onValueChange={setSelectedTabloId}>
<SelectTrigger id="tablo" className="w-full">
<SelectValue placeholder="Sélectionner un tablo" />
</SelectTrigger>
<SelectContent>
{tablos.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${tablo.color || "bg-muted-foreground"}`}
/>
{tablo.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Titre *</Label>

View file

@ -44,6 +44,51 @@ const invalidateEtapeCaches = (queryClient: QueryClient, tabloId?: string) => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
// Fetch all tasks across all tablos where user is a member
export const useAllTasks = () => {
return useQuery({
queryKey: ["tasks", "all"],
queryFn: async () => {
// Fetch tasks (RLS will automatically filter to tablos user has access to)
const { data: tasks, error: tasksError } = await supabase
.from("tasks_with_assignee")
.select("*")
.eq("is_parent", false)
.order("updated_at", { ascending: false });
if (tasksError) throw tasksError;
// Get unique tablo IDs
const tabloIds = [...new Set(tasks?.map((t) => t.tablo_id).filter(Boolean) || [])];
// Fetch tablo information (only if we have tablo IDs)
let tablos: { id: string; name: string; color: string | null }[] = [];
if (tabloIds.length > 0) {
const { data, error: tablosError } = await supabase
.from("tablos")
.select("id, name, color")
.in("id", tabloIds);
if (tablosError) throw tablosError;
tablos = data || [];
}
// Create a map for quick lookup
const tabloMap = new Map(
tablos?.map((t) => [t.id, { id: t.id, name: t.name, color: t.color }]) || []
);
// Merge tasks with tablo information
return (tasks || []).map((task) => ({
...task,
tablos: tabloMap.get(task.tablo_id) || null,
})) as (KanbanTask & {
tablos: { id: string; name: string; color: string | null } | null;
})[];
},
});
};
// Fetch all tasks for a specific tablo
export const useTasksByTablo = (tabloId: string | undefined) => {
return useQuery({

View file

@ -7,13 +7,12 @@ import { AvailabilitiesPage } from "../pages/availabilities";
import { ChantiersPage } from "../pages/chantiers";
import { ChatPage } from "../pages/chat";
import { ConfirmEmailPage } from "../pages/confirm-email";
import { EventsPage } from "../pages/events";
import { FeedbackPage } from "../pages/feedback";
import { JoinPage } from "../pages/join";
import { LegalNoticePage } from "../pages/legal-notice";
import { LoginPage } from "../pages/login";
import { NotFoundPage } from "../pages/NotFoundPage";
import NotesPage from "../pages/notes";
// import NotesPage from "../pages/notes"; // Notes feature temporarily hidden
import { OAuthSigninPage } from "../pages/oauth-signin";
import { PlanningPage } from "../pages/planning";
import { PrivacyPolicyPage } from "../pages/privacy-policy";
@ -22,6 +21,7 @@ import SettingsPage from "../pages/settings";
import { SignUpPage } from "../pages/signup";
import { TabloPage } from "../pages/tablo";
import { TabloDetailsPage } from "../pages/tablo-details";
import { TasksPage } from "../pages/tasks";
import { UpdatePasswordPage } from "../pages/update-password";
import ChatProvider from "../providers/ChatProvider";
@ -77,23 +77,28 @@ export const routes: RouteObject[] = [
),
children: [{ index: true }, { path: ":channelId" }],
},
{
path: "notes",
children: [
{ index: true, element: <NotesPage mode="create" /> },
{ path: ":noteId", element: <NotesPage mode="edit" /> },
{ path: "create", element: <NotesPage mode="create" /> },
],
},
// Notes feature temporarily hidden
// {
// path: "notes",
// children: [
// { index: true, element: <NotesPage mode="create" /> },
// { path: ":noteId", element: <NotesPage mode="edit" /> },
// { path: "create", element: <NotesPage mode="create" /> },
// ],
// },
{
path: "availabilities",
element: <AvailabilitiesPage />,
},
{
path: "events",
element: <EventsPage />,
element: <PlanningPage />,
children: [{ index: true }, { path: "create", element: <EventModal mode="create" /> }],
},
{
path: "tasks",
element: <TasksPage />,
},
{
path: "feedback",
element: <FeedbackPage />,

View file

@ -2,6 +2,7 @@
"projects": "Tablos",
"myEvents": "My Events",
"planning": "Planning",
"tasks": "Tasks",
"discussions": "Discussions",
"notes": "Notes",
"feedback": "Feedback",

View file

@ -85,6 +85,40 @@
}
}
},
"tasks": {
"title": "My Tasks",
"subtitle": "Manage all your tasks across all your tablos",
"search": "Search for a task...",
"filters": {
"allTablos": "All boards",
"allAssignees": "All assignees",
"assignedToMe": "Assigned to me",
"unassigned": "Unassigned"
},
"emptyState": {
"title": "No tasks found",
"noResults": "Try changing your search filters.",
"noTasks": "Start by creating your first task in a tablo."
},
"unassigned": "Unassigned",
"pagination": {
"showing": "Showing {{start}} to {{end}} of {{total}} tasks",
"itemsPerPage": "Items per page:",
"previous": "Previous",
"next": "Next"
},
"stats": {
"found": "Tasks found",
"todo": "To Do",
"inProgress": "In Progress",
"done": "Done"
},
"view": {
"kanban": "Kanban View",
"aggregated": "By Tablo View"
},
"createTask": "New Task"
},
"feedback": {
"title": "Send feedback",
"subtitle": "Help us improve XTablo by sharing your ideas",

View file

@ -22,5 +22,9 @@
"inProgress": "in progress",
"completed_singular": "completed",
"completed_plural": "completed"
},
"events": {
"title": "Upcoming events",
"description": "Manage the future events of this tablo"
}
}

View file

@ -2,6 +2,7 @@
"projects": "Tablos",
"myEvents": "Mes Événements",
"planning": "Planning",
"tasks": "Tâches",
"discussions": "Discussions",
"notes": "Notes",
"feedback": "Feedback",

View file

@ -53,7 +53,7 @@
"createEventType": "Nouveau type",
"search": "Rechercher un événement...",
"filters": {
"allTablos": "Tous les tableaux",
"allTablos": "Tous les tablos",
"upcoming": "À venir",
"past": "Passés"
},
@ -85,6 +85,40 @@
}
}
},
"tasks": {
"title": "Mes Tâches",
"subtitle": "Gérez toutes vos tâches à travers tous vos tablos",
"search": "Rechercher une tâche...",
"filters": {
"allTablos": "Tous les tablos",
"allAssignees": "Tous les assignés",
"assignedToMe": "Assignées à moi",
"unassigned": "Non assignées"
},
"emptyState": {
"title": "Aucune tâche trouvée",
"noResults": "Essayez de modifier vos filtres de recherche.",
"noTasks": "Commencez par créer votre première tâche dans un tablo."
},
"unassigned": "Non assignée",
"pagination": {
"showing": "Affichage de {{start}} à {{end}} sur {{total}} tâches",
"itemsPerPage": "Éléments par page:",
"previous": "Précédent",
"next": "Suivant"
},
"stats": {
"found": "Tâches trouvées",
"todo": "À faire",
"inProgress": "En cours",
"done": "Terminées"
},
"view": {
"kanban": "Vue Kanban",
"aggregated": "Vue par tablo"
},
"createTask": "Nouvelle tâche"
},
"feedback": {
"title": "Envoyer un commentaire",
"subtitle": "Aidez-nous à améliorer XTablo en partageant vos idées",

View file

@ -22,5 +22,10 @@
"inProgress": "en cours",
"completed_singular": "terminée",
"completed_plural": "terminées"
},
"events": {
"title": "Événements à venir",
"description": "Gérez les événements futurs de ce tablo",
"createEvent": "Créer un événement"
}
}

View file

@ -351,7 +351,7 @@ export function EventsPage() {
onValueChange={(value) => setSelectedTabloId(value)}
>
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
<SelectValue placeholder="Tous les tableaux" />
<SelectValue placeholder="Tous les tablos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("pages:events.filters.allTablos")}</SelectItem>

View file

@ -1,16 +1,14 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import {
ArrowLeft,
BookOpen,
// BookOpen, // Notes feature temporarily hidden
Calendar,
FileText,
LayoutDashboard,
ListChecks,
MessageSquare,
Settings,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
@ -19,31 +17,33 @@ import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloMembersSection } from "../components/TabloMembersSection";
import { TabloNotesSection } from "../components/TabloNotesSection";
// import { TabloNotesSection } from "../components/TabloNotesSection"; // Notes feature temporarily hidden
import { TabloOverviewSection } from "../components/TabloOverviewSection";
import { TabloSettingsSection } from "../components/TabloSettingsSection";
import { TabloTasksSection } from "../components/TabloTasksSection";
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
import { useTablosList } from "../hooks/tablos";
type TabSection =
| "overview"
| "files"
| "discussion"
| "notes"
// | "notes" // Notes feature temporarily hidden
| "events"
| "tasks"
| "members"
| "settings";
| "tasks";
export const TabloDetailsPage = () => {
const { tabloId } = useParams<{ tabloId: string }>();
const navigate = useNavigate();
const { data: tablos, isLoading } = useTablosList();
const { mutateAsync: updateTablo } = useUpdateTablo();
const [searchParams, setSearchParams] = useSearchParams();
const activeSection = (searchParams.get("section") as TabSection) || "overview";
const sectionParam = searchParams.get("section");
// Notes feature temporarily hidden - redirect to overview if notes is selected
const activeSection: TabSection =
sectionParam &&
sectionParam !== "notes" &&
["overview", "files", "discussion", "events", "tasks"].includes(sectionParam)
? (sectionParam as TabSection)
: "overview";
const [tablo, setTablo] = useState<UserTablo | null>(null);
@ -67,29 +67,6 @@ export const TabloDetailsPage = () => {
}
}, [tablos, tabloId, navigate]);
const handleEdit = async (updatedTablo: TabloUpdate & { id: string }) => {
try {
await updateTablo(updatedTablo);
toast.add(
{
title: "Tablo mis à jour",
description: "Les modifications ont été enregistrées",
type: "success",
},
{ timeout: 3000 }
);
} catch (_error) {
toast.add(
{
title: "Erreur",
description: "Impossible de mettre à jour le tablo",
type: "error",
},
{ timeout: 5000 }
);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
@ -114,6 +91,11 @@ export const TabloDetailsPage = () => {
label: "Vue d'ensemble",
icon: <LayoutDashboard className="w-5 h-5" />,
},
{
id: "tasks",
label: "Tâches",
icon: <ListChecks className="w-5 h-5" />,
},
{
id: "files",
label: "Fichiers",
@ -124,31 +106,17 @@ export const TabloDetailsPage = () => {
label: "Discussion",
icon: <MessageSquare className="w-5 h-5" />,
},
{
id: "notes",
label: "Notes",
icon: <BookOpen className="w-5 h-5" />,
},
// Notes feature temporarily hidden
// {
// id: "notes",
// label: "Notes",
// icon: <BookOpen className="w-5 h-5" />,
// },
{
id: "events",
label: "Événements",
icon: <Calendar className="w-5 h-5" />,
},
{
id: "tasks",
label: "Tâches",
icon: <ListChecks className="w-5 h-5" />,
},
{
id: "members",
label: "Membres",
icon: <Users className="w-5 h-5" />,
},
{
id: "settings",
label: "Paramètres",
icon: <Settings className="w-5 h-5" />,
},
];
return (
@ -221,13 +189,10 @@ export const TabloDetailsPage = () => {
.with("overview", () => <TabloOverviewSection tablo={tablo} isAdmin={isAdmin} />)
.with("files", () => <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />)
.with("discussion", () => <TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />)
.with("notes", () => <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />)
// Notes feature temporarily hidden
// .with("notes", () => <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />)
.with("events", () => <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />)
.with("tasks", () => <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />)
.with("members", () => <TabloMembersSection tablo={tablo} isAdmin={isAdmin} />)
.with("settings", () => (
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
))
.exhaustive()}
</div>
</main>

View file

@ -46,6 +46,9 @@ type FilterOption = {
export const TabloPage = () => {
const { t } = useTranslation(["pages", "common"]);
const shouldShowKpis = false;
const [contextMenuTablo, setContextMenuTablo] = useState<string | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{
x: number;
@ -655,7 +658,7 @@ export const TabloPage = () => {
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* KPI Section */}
{kpis && !isReadOnly && (
{shouldShowKpis && kpis && !isReadOnly && (
<div className="mb-8">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
{/* Total Tablos */}

View file

@ -0,0 +1,613 @@
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 { Button } from "@xtablo/ui/components/button";
import { Kanban, LayoutGrid, ListTodo, PlusIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
import { useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { TaskModal } from "../components/kanban/TaskModal";
type TaskStatus = "all" | "todo" | "in_progress" | "in_review" | "done";
type TaskWithTablo = KanbanTask & {
tablos: { id: string; name: string; color: string | null } | null;
};
const statusLabels: Record<TaskStatus, string> = {
all: "Tous",
todo: "À faire",
in_progress: "En cours",
in_review: "Vérification",
done: "Terminé",
};
const columnTitles = {
todo: "À faire",
in_progress: "En cours",
in_review: "Vérification",
done: "Terminé",
};
export function TasksPage() {
const navigate = useNavigate();
const { t } = useTranslation(["pages", "common"]);
const user = useUser();
const [searchParams, setSearchParams] = useSearchParams();
const [selectedTabloId, setSelectedTabloId] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<TaskStatus>("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
// Get view mode from URL params, default to "kanban"
const viewMode = (searchParams.get("view") as "kanban" | "aggregated") || "kanban";
// Function to update view mode in URL
const setViewMode = (mode: "kanban" | "aggregated") => {
const newParams = new URLSearchParams(searchParams);
newParams.set("view", mode);
setSearchParams(newParams);
};
// Fetch data
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: allTasks = [], isLoading: tasksLoading } = useAllTasks();
// Mutation for updating task status
const updateTaskMutation = useUpdateTask();
// Filter and search tasks
const filteredTasks = useMemo(() => {
let filtered = allTasks as TaskWithTablo[];
// Tablo filter
if (selectedTabloId !== "all") {
filtered = filtered.filter((task) => task.tablo_id === selectedTabloId);
}
// Status filter (only applies if not "all")
if (statusFilter !== "all") {
filtered = filtered.filter((task) => task.status === statusFilter);
}
// Assignee filter
if (assigneeFilter !== "all") {
if (assigneeFilter === "me") {
filtered = filtered.filter((task) => task.assignee_id === user.id);
} else if (assigneeFilter === "unassigned") {
filtered = filtered.filter((task) => !task.assignee_id);
} else {
filtered = filtered.filter((task) => task.assignee_id === assigneeFilter);
}
}
return filtered;
}, [allTasks, selectedTabloId, statusFilter, assigneeFilter, user.id]);
// Initialize Kanban columns from filtered tasks
const columns = useMemo((): KanbanColumn[] => {
const defaultColumns: KanbanColumn[] = [
{
id: "todo",
title: columnTitles.todo,
status: "todo",
position: 0,
tasks: filteredTasks.filter((task) => task.status === "todo"),
},
{
id: "in_progress",
title: columnTitles.in_progress,
status: "in_progress",
position: 1,
tasks: filteredTasks.filter((task) => task.status === "in_progress"),
},
{
id: "in_review",
title: columnTitles.in_review,
status: "in_review",
position: 2,
tasks: filteredTasks.filter((task) => task.status === "in_review"),
},
{
id: "done",
title: columnTitles.done,
status: "done",
position: 3,
tasks: filteredTasks.filter((task) => task.status === "done"),
},
];
return defaultColumns;
}, [filteredTasks]);
// Get unique assignees from tasks
const assignees = useMemo(() => {
const assigneeMap = new Map<string, { id: string; name: string }>();
allTasks.forEach((task) => {
if (task.assignee_id && task.assignee_name) {
if (!assigneeMap.has(task.assignee_id)) {
assigneeMap.set(task.assignee_id, {
id: task.assignee_id,
name: task.assignee_name,
});
}
}
});
return Array.from(assigneeMap.values());
}, [allTasks]);
// Group tasks by tablo for aggregated view
const tasksByTablo = useMemo(() => {
const grouped = new Map<string, TaskWithTablo[]>();
filteredTasks.forEach((task) => {
const tabloId = task.tablo_id || "unknown";
if (!grouped.has(tabloId)) {
grouped.set(tabloId, []);
}
grouped.get(tabloId)!.push(task);
});
return grouped;
}, [filteredTasks]);
const handleTaskClick = (task: KanbanTask) => {
if (task.tablo_id) {
navigate(`/tablos/${task.tablo_id}?section=tasks`);
}
};
const handleDragStart = (e: React.DragEvent, task: KanbanTask) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("taskId", task.id);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDrop = (
e: React.DragEvent,
targetStatus: "todo" | "in_progress" | "in_review" | "done"
) => {
e.preventDefault();
const taskId = e.dataTransfer.getData("taskId");
if (!taskId) return;
// Find the task to update
const task = filteredTasks.find((t) => t.id === taskId);
if (!task || task.status === targetStatus) return;
// Update the task status
updateTaskMutation.mutate({
id: taskId,
status: targetStatus,
});
};
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>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-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 />
</div>
) : filteredTasks.length === 0 ? (
<div className="p-12 text-center">
<ListTodo className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 text-sm font-medium text-foreground">
{t("pages:tasks.emptyState.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{statusFilter !== "all" || selectedTabloId !== "all"
? t("pages:tasks.emptyState.noResults")
: t("pages:tasks.emptyState.noTasks")}
</p>
</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>
{/* Tasks */}
<div
className="flex-1 space-y-2 overflow-y-auto min-h-[200px]"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, column.status)}
>
{column.tasks.length === 0 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
{t("pages:tasks.emptyState.noTasks")}
</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>
{task.description && (
<p className="text-muted-foreground text-sm line-clamp-2 mt-1 mb-2">
{task.description}
</p>
)}
{/* Tablo Badge */}
{taskWithTablo.tablos && (
<div className="mb-2">
<span
className={twMerge(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
taskWithTablo.tablos.color,
getTextColorFromTabloColor(taskWithTablo.tablos.color || "")
)}
>
{taskWithTablo.tablos.name}
</span>
</div>
)}
{/* Assignee */}
<div className="flex items-center justify-end mt-2">
<div className="flex items-center gap-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>
) : (
<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>
)}
</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">
{tablosLoading || tasksLoading ? (
<div className="flex items-center justify-center h-64">
<LoadingSpinner />
</div>
) : filteredTasks.length === 0 ? (
<div className="p-12 text-center">
<ListTodo className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 text-sm font-medium text-foreground">
{t("pages:tasks.emptyState.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{statusFilter !== "all" || selectedTabloId !== "all"
? t("pages:tasks.emptyState.noResults")
: t("pages:tasks.emptyState.noTasks")}
</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",
};
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>
);
};
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}
</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>
)}
<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>
</div>
)}
</div>
)}
</main>
{/* Task Create Modal */}
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
tablos={tablos}
allowTabloSelection={true}
initialStatus="todo"
/>
</div>
);
}