Big UI update
Remove notes and follow the figma
This commit is contained in:
parent
50a27fea98
commit
f152d7d45a
22 changed files with 1259 additions and 135 deletions
|
|
@ -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")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
322
apps/main/src/components/TabloHeaderActions.tsx
Normal file
322
apps/main/src/components/TabloHeaderActions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"projects": "Tablos",
|
||||
"myEvents": "My Events",
|
||||
"planning": "Planning",
|
||||
"tasks": "Tasks",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"feedback": "Feedback",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"projects": "Tablos",
|
||||
"myEvents": "Mes Événements",
|
||||
"planning": "Planning",
|
||||
"tasks": "Tâches",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"feedback": "Feedback",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
613
apps/main/src/pages/tasks.tsx
Normal file
613
apps/main/src/pages/tasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue