Redesign overview dashboard with new project cards and task list

- Replace renderTabloListView with reusable ProjectCard / ProjectCardList components
  - Card layout with status badge, progress bar, date, and delete action
  - Default view shows 6 tablos with expand/collapse toggle
- Add DashboardTaskList component showing tasks assigned to the current user
  - Toggle done/todo inline; "Add Task" button opens TaskModal with tablo selection
- Wire TopBar search input to URL param ?q= to filter tablos on the overview page
- Add TopBar component to Layout (was missing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-02-21 14:35:12 +01:00
parent 4347adedd9
commit a3f5cf5e4e
No known key found for this signature in database
15 changed files with 1163 additions and 286 deletions

View file

@ -0,0 +1,77 @@
import { cn } from "@xtablo/shared";
import { ReactNode } from "react";
export interface ActionCardProps {
icon: ReactNode;
label: string;
description: string;
variant?: "primary" | "default";
isSelected?: boolean;
onClick?: () => void;
className?: string;
}
export function ActionCard({
icon,
label,
description,
variant = "default",
isSelected = false,
onClick,
className,
}: ActionCardProps) {
const isPrimary = variant === "primary";
const isActive = isSelected || isPrimary;
return (
<button
onClick={onClick}
className={cn(
"h-fit p-3 rounded-2xl text-left transition-all",
isSelected
? "bg-[rgb(128,78,236)] text-white border-transparent shadow-lg"
: isPrimary
? "bg-primary text-white hover:shadow-lg"
: "bg-white border border-[#EAECF0] hover:shadow-md",
className,
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"w-10 h-10 rounded-[8px] flex items-center justify-center flex-shrink-0",
isActive ? "bg-white/20" : "bg-[#F4F3FF]",
)}
>
<span
className={cn(
"w-6 h-6 flex items-center justify-center",
isActive ? "text-white" : "text-[#7F56D9]",
)}
>
{icon}
</span>
</div>
<div>
<span
className={cn(
"block font-semibold text-lg leading-tight",
isActive ? "text-white" : "text-gray-900",
)}
>
{label}
</span>
<p
className={cn(
"text-sm mt-0.5",
isActive ? "text-purple-100" : "text-gray-500",
)}
>
{description}
</p>
</div>
</div>
</button>
);
}

View file

@ -0,0 +1,62 @@
import { FolderPlus, MessageCircle, PlusCircle, UserPlus } from "lucide-react";
import { useState } from "react";
import { ActionCard } from "./ActionCard";
export interface DashboardActionCardsProps {
onCreateProject?: () => void;
onCreateTask?: () => void;
onInviteTeam?: () => void;
onSendMessage?: () => void;
}
type CardId = "createProject" | "createTask" | "inviteTeam" | "sendMessage";
export function DashboardActionCards({
onCreateProject,
onCreateTask,
onInviteTeam,
onSendMessage,
}: DashboardActionCardsProps) {
const [selected, setSelected] = useState<CardId | null>(null);
const handleClick = (id: CardId, callback?: () => void) => {
setSelected(id);
callback?.();
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
<ActionCard
icon={<FolderPlus className="w-6 h-6" />}
label="Create Project"
description="Set goals and scope"
isSelected={selected === "createProject"}
onClick={() => handleClick("createProject", onCreateProject)}
/>
<ActionCard
icon={<PlusCircle className="w-6 h-6" />}
label="Create Task"
description="Break work into actions"
isSelected={selected === "createTask"}
onClick={() => handleClick("createTask", onCreateTask)}
/>
<ActionCard
icon={<UserPlus className="w-6 h-6" />}
label="Invite Team"
description="Add collaborators instantly"
isSelected={selected === "inviteTeam"}
onClick={() => handleClick("inviteTeam", onInviteTeam)}
/>
<ActionCard
icon={<MessageCircle className="w-6 h-6" />}
label="Send Message"
description="Communicate updates fast"
isSelected={selected === "sendMessage"}
onClick={() => handleClick("sendMessage", onSendMessage)}
/>
</div>
);
}

View file

@ -0,0 +1,191 @@
import { cn } from "@xtablo/shared";
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
import { CheckCircle2, Plus } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useTablosList } from "../hooks/tablos";
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
import { TaskModal } from "./kanban/TaskModal";
type TaskWithTablo = KanbanTask & {
tablos: { id: string; name: string; color: string | null } | null;
};
const STATUS_BADGE: Record<
TaskStatus,
{ className: string; labelKey: string }
> = {
todo: {
className: "bg-blue-50 text-blue-600",
labelKey: "dashboard.taskList.status.todo",
},
in_progress: {
className: "bg-yellow-50 text-yellow-600",
labelKey: "dashboard.taskList.status.inProgress",
},
in_review: {
className: "bg-purple-50 text-purple-600",
labelKey: "dashboard.taskList.status.inReview",
},
done: {
className: "bg-green-50 text-green-600",
labelKey: "dashboard.taskList.status.done",
},
};
function TaskRow({
task,
onToggleDone,
}: {
task: TaskWithTablo;
onToggleDone: (task: TaskWithTablo) => void;
}) {
const { t } = useTranslation("pages");
const navigate = useNavigate();
const status = task.status ?? "todo";
const isDone = status === "done";
const badge = STATUS_BADGE[status];
const dateStr = task.updated_at ?? task.created_at;
const formattedDate = dateStr
? new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(dateStr))
: "";
return (
<div
className="flex items-center justify-between gap-4 p-4 hover:bg-gray-50 transition-colors border-b border-gray-200 cursor-pointer"
onClick={() => {
if (task.tablos) {
navigate(`/tablos/${task.tablos.id}?section=tasks`);
}
}}
>
{/* Checkbox + Title */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<button
className={cn(
"w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0",
isDone
? "bg-purple-600 border-purple-600"
: "border-gray-300 hover:border-purple-400",
)}
onClick={(e) => {
e.stopPropagation();
onToggleDone(task);
}}
>
{isDone && <CheckCircle2 className="w-4 h-4 text-white" />}
</button>
<p
className={cn(
"text-sm font-medium truncate",
isDone ? "line-through text-gray-400" : "text-gray-900",
)}
>
{task.title}
</p>
</div>
{/* Tablo */}
{task.tablos && (
<div className="flex items-center gap-2 shrink-0">
<div
className={cn(
"w-6 h-6 rounded-lg flex items-center justify-center text-xs shrink-0",
task.tablos.color || "bg-gray-400",
)}
>
<span className="text-white font-bold text-[10px]">
{task.tablos.name.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-sm text-[#0C111D] hidden sm:inline max-w-[140px] truncate">
{task.tablos.name}
</span>
</div>
)}
{/* Date */}
<span className="text-sm text-[#0C111D] shrink-0 hidden md:inline">
{formattedDate}
</span>
{/* Status badge */}
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium shrink-0",
badge.className,
)}
>
{t(badge.labelKey)}
</span>
</div>
);
}
export function DashboardTaskList() {
const { t } = useTranslation("pages");
const user = useUser();
const { data: allTasks } = useAllTasks();
const { data: tablos } = useTablosList();
const updateTask = useUpdateTask();
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
// Filter to tasks assigned to the current user, limited to recent ones
const myTasks =
allTasks
?.filter((task) => task.assignee_id === user.id)
.slice(0, 7) ?? [];
const handleToggleDone = (task: TaskWithTablo) => {
const newStatus: TaskStatus =
task.status === "done" ? "todo" : "done";
updateTask.mutate({ id: task.id, status: newStatus });
};
if (myTasks.length === 0) return null;
return (
<>
<div className="bg-white rounded-2xl border border-gray-100">
<div className="flex items-center justify-between px-4 py-5 border-b border-gray-200">
<h2 className="text-2xl font-semibold text-gray-900">
{t("dashboard.taskList.title")}
</h2>
<button
className="flex items-center gap-2 px-4 py-2 bg-white rounded-lg border border-gray-200 hover:bg-gray-50"
onClick={() => setIsTaskModalOpen(true)}
>
<Plus className="w-4 h-4" />
<span>{t("dashboard.taskList.addTask")}</span>
</button>
</div>
<div className="overflow-x-auto">
<div className="min-w-[600px]">
{myTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggleDone={handleToggleDone}
/>
))}
</div>
</div>
</div>
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
tablos={tablos}
allowTabloSelection
initialStatus="todo"
/>
</>
);
}

View file

@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { SideNavigation } from "./NavigationBar";
import { OnboardingModal } from "./OnboardingModal";
import { TopBar } from "./TopBar";
const ONBOARDING_STORAGE_KEY = "xtablo-onboarding-completed";
@ -50,9 +51,12 @@ export function Layout() {
<SideNavigation isMobileMenuOpen={isMobileMenuOpen} />
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
}

View file

@ -21,6 +21,7 @@ import {
CreditCard,
// FileTextIcon, // Notes feature temporarily hidden
Kanban,
LayersIcon,
ListTodo,
LogOutIcon,
MessageCircleIcon,
@ -42,7 +43,6 @@ import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
import { isProd, isStaging } from "../lib/env";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { getXtabloIcon } from "../utils/iconHelpers";
import { NotificationPanel } from "./NotificationPanel";
import { ThemeSwitcher } from "./ThemeSwitcher";
type NavLinkItem = {
@ -56,7 +56,7 @@ function NavLink({ isActive, children }: NavLinkProps) {
return (
<div
className={twMerge(
"group w-full gap-x-3 overflow-hidden px-2.5 py-1.5 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[data-ui=icon]:not([class*=size-])]:size-4.5",
"group w-full gap-x-3 overflow-hidden px-2.5 py-2 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[data-ui=icon]:not([class*=size-])]:size-5",
"*:data-[ui=notification-badge]:bg-navbar-darker",
"*:data-[ui=notification-badge]:rounded-md",
"*:data-[ui=notification-badge]:top-1/2",
@ -282,7 +282,6 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
isCollapsed ? "pl-2.5 pr-3.5" : ""
)}
>
<NotificationPanel isCollapsed={isCollapsed} />
<UserMenuPopover isCollapsed={isCollapsed} />
</div>
</nav>
@ -322,7 +321,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
? [
{
path: "/",
label: t("projects"),
label: t("home"),
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{
@ -334,7 +333,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
: [
{
path: "/",
label: t("projects"),
label: t("home"),
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{
@ -355,6 +354,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("tasks"),
icon: <ListTodo className="w-5 h-5" />,
},
{
path: "/tablos",
label: t("tablos"),
icon: <LayersIcon className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/planning",
@ -396,11 +400,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
className="w-full"
aria-label={isCollapsed ? label : undefined}
>
<div className={twMerge("flex items-center gap-x-2", isCollapsed ? "" : "pl-2")}>
{icon}
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
<span className="[&>svg]:w-6 [&>svg]:h-6">{icon}</span>
<TypographyLarge
className={twMerge(
"text-sm transition-all duration-300 font-normal",
"text-base transition-all duration-300 font-normal",
isActive ? "text-white" : "text-gray-300/90",
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
)}
@ -555,11 +559,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
className="w-full"
aria-label={isCollapsed ? "Feedback" : undefined}
>
<div className={twMerge("flex items-center gap-x-2", isCollapsed ? "" : "pl-2")}>
<SendIcon className="w-5 h-5" aria-hidden="true" />
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
<span className="[&>svg]:w-6 [&>svg]:h-6"><SendIcon aria-hidden="true" /></span>
<TypographyLarge
className={twMerge(
"text-sm transition-all duration-300 font-normal",
"text-base transition-all duration-300 font-normal",
location.pathname === "/feedback" ? "text-white" : "text-gray-300/90",
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
)}

View file

@ -0,0 +1,157 @@
import { cn } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Calendar, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
type StatusConfig = {
label: string;
badgeClass: string;
progressColor: string;
};
function useStatusConfig(status: string): StatusConfig {
const { t } = useTranslation("pages");
switch (status) {
case "todo":
return {
label: t("tablo.status.todo"),
badgeClass: "bg-blue-50 text-blue-600 border-blue-200",
progressColor: "bg-blue-500",
};
case "in_progress":
return {
label: t("tablo.status.inProgress"),
badgeClass: "bg-yellow-50 text-yellow-600 border-yellow-200",
progressColor: "bg-purple-500",
};
case "done":
return {
label: t("tablo.status.done"),
badgeClass: "bg-green-50 text-green-600 border-green-200",
progressColor: "bg-green-500",
};
default:
return {
label: t("tablo.status.todo"),
badgeClass: "bg-blue-50 text-blue-600 border-blue-200",
progressColor: "bg-blue-500",
};
}
}
function getProgressFromStatus(status: string): number {
switch (status) {
case "todo":
return 0;
case "in_progress":
return 50;
case "done":
return 100;
default:
return 0;
}
}
export interface ProjectCardProps {
tablo: UserTablo;
onClick?: (tabloId: string) => void;
onMenuClick?: (tabloId: string) => void;
className?: string;
}
export function ProjectCard({
tablo,
onClick,
onMenuClick,
className,
}: ProjectCardProps) {
const { t } = useTranslation("pages");
const statusConfig = useStatusConfig(tablo.status);
const progress = getProgressFromStatus(tablo.status);
const formattedDate = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(tablo.created_at));
return (
<div
className={cn(
"bg-white rounded-2xl p-4 border border-[#EAECF0] hover:shadow-md transition-shadow cursor-pointer",
className,
)}
onClick={() => onClick?.(tablo.id)}
>
{/* Status + Menu */}
<div className="flex items-start justify-between mb-4">
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border",
statusConfig.badgeClass,
)}
>
{statusConfig.label}
</span>
<button
className="text-gray-400 hover:text-red-500"
onClick={(e) => {
e.stopPropagation();
onMenuClick?.(tablo.id);
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Thumbnail + Name */}
<div className="flex items-center gap-3 mb-4">
<div
className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center shrink-0 overflow-hidden",
!tablo.image && (tablo.color || "bg-gray-400"),
)}
>
{tablo.image ? (
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-white font-bold text-lg">
{tablo.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<h3 className="font-semibold text-gray-900 flex-1 truncate">
{tablo.name}
</h3>
</div>
{/* Date */}
<div className="flex items-center gap-2 text-sm text-gray-500 mb-4">
<Calendar className="w-4 h-4" />
<span>{formattedDate}</span>
</div>
{/* Progress */}
<div className="mb-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600">{t("tablo.card.progress")}:</span>
<span className="font-semibold text-gray-900">{progress}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
statusConfig.progressColor,
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { ChevronDown, ChevronRight, ChevronUp } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ProjectCard } from "./ProjectCard";
const DEFAULT_VISIBLE = 6;
export interface ProjectCardListProps {
tablos: UserTablo[];
onTabloClick?: (tabloId: string) => void;
onTabloMenuClick?: (tabloId: string) => void;
onSeeAllClick?: () => void;
}
export function ProjectCardList({
tablos,
onTabloClick,
onTabloMenuClick,
onSeeAllClick,
}: ProjectCardListProps) {
const { t } = useTranslation("pages");
const [expanded, setExpanded] = useState(false);
const hasMore = tablos.length > DEFAULT_VISIBLE;
const visibleTablos = expanded ? tablos : tablos.slice(0, DEFAULT_VISIBLE);
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-gray-900">
{t("tablo.projectList.title")}
</h2>
{onSeeAllClick && (
<button
className="flex items-center gap-1 text-purple-600 hover:text-purple-700 font-medium"
onClick={onSeeAllClick}
>
{t("tablo.projectList.seeAll")}
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{visibleTablos.map((tablo) => (
<ProjectCard
key={tablo.id}
tablo={tablo}
onClick={onTabloClick}
onMenuClick={onTabloMenuClick}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-6">
<button
className="flex items-center gap-1.5 text-purple-600 hover:text-purple-700 font-medium text-sm"
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? (
<>
{t("tablo.projectList.showLess")}
<ChevronUp className="w-4 h-4" />
</>
) : (
<>
{t("tablo.projectList.showAll", {
count: tablos.length - DEFAULT_VISIBLE,
})}
<ChevronDown className="w-4 h-4" />
</>
)}
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,362 @@
import type { Database } from "@xtablo/shared-types";
import { Badge } from "@xtablo/ui/components/badge";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@xtablo/ui/components/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { TypographyMuted, TypographySmall } from "@xtablo/ui/components/typography";
import {
BellIcon,
CalendarCheckIcon,
CalendarIcon,
CheckCheckIcon,
FileTextIcon,
KanbanIcon,
LayoutDashboardIcon,
LogOutIcon,
MailIcon,
SearchIcon,
SettingsIcon,
UserPlusIcon,
XIcon,
} from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { useLogout } from "../hooks/auth";
import { useNotifications, useNotificationsSubscription } from "../hooks/notifications";
import { useUser } from "../providers/UserStoreProvider";
type Notification = Database["public"]["Tables"]["notifications"]["Row"];
function getNotificationIcon(entityType: string) {
switch (entityType) {
case "tablos":
return <LayoutDashboardIcon className="w-4 h-4" />;
case "tasks":
return <KanbanIcon className="w-4 h-4" />;
case "events":
return <CalendarIcon className="w-4 h-4" />;
case "notes":
return <FileTextIcon className="w-4 h-4" />;
case "tablo_access":
return <UserPlusIcon className="w-4 h-4" />;
case "tablo_invites":
return <MailIcon className="w-4 h-4" />;
default:
return <BellIcon className="w-4 h-4" />;
}
}
function getNotificationLink(notification: Notification): string {
const { entity_type, entity_id, metadata } = notification;
switch (entity_type) {
case "tablos":
return `/tablos/${entity_id}`;
case "tasks":
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
return `/tablos/${metadata.tablo_id}`;
}
return "/";
case "events":
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
return `/tablos/${metadata.tablo_id}`;
}
return "/planning?tab=events";
case "notes":
return `/notes/${entity_id}`;
case "tablo_access":
case "tablo_invites":
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
return `/tablos/${metadata.tablo_id}`;
}
return "/";
default:
return "/";
}
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return "Just now";
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}h ago`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays}d ago`;
return date.toLocaleDateString();
}
function NotificationItem({
notification,
onMarkAsRead,
}: {
notification: Notification;
onMarkAsRead: (id: string) => void;
}) {
const { i18n } = useTranslation();
const link = getNotificationLink(notification);
const getMessage = () => {
const locale = i18n.language.startsWith("fr") ? "fr" : "en";
return (
(notification.message as Record<string, string>)[locale] ||
(notification.message as Record<string, string>)["en"] ||
""
);
};
return (
<Link to={link} onClick={() => onMarkAsRead(notification.id)}>
<DropdownMenuItem className="cursor-pointer p-3 focus:bg-gray-100 hover:bg-gray-100 text-gray-800">
<div className="flex gap-3 w-full">
<div className="shrink-0 mt-1">
<div className="p-2 rounded-full bg-blue-100 text-blue-600">
{getNotificationIcon(notification.entity_type)}
</div>
</div>
<div className="flex-1 min-w-0">
<TypographySmall className="font-medium text-gray-900 line-clamp-2">
{getMessage()}
</TypographySmall>
<TypographyMuted className="text-xs mt-1 text-gray-500">
{formatRelativeTime(notification.created_at)}
</TypographyMuted>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-200 text-gray-500 hover:text-gray-900"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onMarkAsRead(notification.id);
}}
>
<XIcon className="h-3 w-3" />
</Button>
</div>
</DropdownMenuItem>
</Link>
);
}
function NotificationDropdown() {
const { t } = useTranslation("navigation");
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNotifications();
const { setupSubscription } = useNotificationsSubscription();
useEffect(() => {
const cleanup = setupSubscription();
return cleanup;
}, []);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative w-10 h-10 border border-[#EAECF0] rounded-[8px] text-[#0C111D] hover:bg-gray-100"
aria-label="Notifications"
>
<BellIcon className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 rounded-full bg-red-500 text-white text-[10px] font-semibold leading-none">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-96 bg-white border border-[#EAECF0] p-1 rounded-lg text-gray-900 shadow-lg"
side="bottom"
align="end"
sideOffset={8}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<TypographySmall className="font-semibold text-gray-900">
{t("notifications.title", "Notifications")}
</TypographySmall>
{unreadCount > 0 && (
<Badge className="bg-red-500 text-white text-xs">{unreadCount}</Badge>
)}
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs text-gray-600 hover:text-gray-900 hover:bg-gray-100"
onClick={() => markAllAsRead()}
>
<CheckCheckIcon className="h-3 w-3 mr-1" />
{t("notifications.markAllRead", "Mark all read")}
</Button>
)}
</div>
<div className="max-h-[400px] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<TypographyMuted className="text-sm text-gray-500">
{t("notifications.loading", "Loading...")}
</TypographyMuted>
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="p-3 rounded-full bg-gray-100 mb-3">
<BellIcon className="h-6 w-6 text-gray-400" />
</div>
<TypographySmall className="font-medium text-gray-900 mb-1">
{t("notifications.noNotifications", "No new notifications")}
</TypographySmall>
<TypographyMuted className="text-xs text-gray-500">
{t("notifications.allCaughtUp", "You're all caught up!")}
</TypographyMuted>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map((notification) => (
<div key={notification.id} className="group">
<NotificationItem notification={notification} onMarkAsRead={markAsRead} />
</div>
))}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProfileDropdown() {
const { t } = useTranslation("navigation");
const user = useUser();
const { mutate: logout } = useLogout();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-2 p-1 hover:bg-gray-100 rounded-[8px]"
aria-label="Profile menu"
>
<Avatar className="w-10 h-10">
<AvatarImage src={user.avatar_url ?? undefined} alt="Avatar" />
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
{user.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 bg-white border border-[#EAECF0] p-1 rounded-lg text-gray-900 shadow-lg"
side="bottom"
align="end"
sideOffset={8}
>
<div className="flex gap-2 p-2">
<Avatar className="size-8">
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
{user.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<TypographySmall className="font-semibold text-gray-900 truncate">
{user.name}
</TypographySmall>
<TypographyMuted className="text-xs text-gray-500 truncate">{user.email}</TypographyMuted>
</div>
</div>
<DropdownMenuSeparator className="bg-gray-100" />
<Link to="/settings">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 focus:bg-gray-100">
<SettingsIcon className="w-4 h-4" />
{t("userMenu.settings")}
</DropdownMenuItem>
</Link>
<Link to="/events">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 focus:bg-gray-100">
<CalendarCheckIcon className="w-4 h-4" />
{t("myEvents")}
</DropdownMenuItem>
</Link>
<Link to="/availabilities">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 focus:bg-gray-100">
<CalendarIcon className="w-4 h-4" />
{t("userMenu.availabilities")}
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator className="bg-gray-100" />
<DropdownMenuItem
className="cursor-pointer gap-2 text-red-600 focus:bg-red-50 focus:text-red-600"
onClick={logout}
>
<LogOutIcon className="w-4 h-4" />
{t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
const SEARCH_ROUTES = ["/tablos", "/"];
export function TopBar() {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const searchQuery = searchParams.get("q") ?? "";
const isSearchRoute = SEARCH_ROUTES.includes(location.pathname);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newParams = new URLSearchParams(searchParams);
if (e.target.value) {
newParams.set("q", e.target.value);
} else {
newParams.delete("q");
}
setSearchParams(newParams, { replace: true });
};
return (
<header className="h-[75px] flex items-center justify-between px-6 gap-4 border-b border-[#EAECF0] bg-white shrink-0">
<div className="relative flex-1 max-w-sm">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
<input
type="text"
placeholder="Search..."
value={isSearchRoute ? searchQuery : ""}
onChange={isSearchRoute ? handleSearchChange : undefined}
readOnly={!isSearchRoute}
className="w-full pl-9 pr-4 py-2 bg-transparent border border-[#EAECF0] rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="flex items-center gap-3">
<NotificationDropdown />
<ProfileDropdown />
</div>
</header>
);
}

View file

@ -21,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 { TablosPage } from "../pages/tablos";
import { TasksPage } from "../pages/tasks";
import { UpdatePasswordPage } from "../pages/update-password";
import ChatProvider from "../providers/ChatProvider";
@ -100,6 +101,10 @@ export const routes: RouteObject[] = [
path: "tasks",
element: <TasksPage />,
},
{
path: "tablos",
element: <TablosPage />,
},
{
path: "feedback",
element: <FeedbackPage />,

View file

@ -1,5 +1,7 @@
{
"projects": "Tablos",
"home": "Home",
"tablos": "Tablos",
"projects": "Home",
"myEvents": "My Events",
"planning": "Planning",
"tasks": "Tasks",

View file

@ -32,6 +32,15 @@
"openPlanning": "Open planning",
"delete": "Delete tablo"
},
"card": {
"progress": "Progress"
},
"projectList": {
"title": "My Tablos",
"seeAll": "See All",
"showAll": "See {{count}} more",
"showLess": "Show less"
},
"kpis": {
"total": "Total",
"todo": "To Do",
@ -150,5 +159,17 @@
"title": "Thank you for your feedback!",
"description": "Your feedback has been sent successfully. We appreciate you taking the time to help us improve."
}
},
"dashboard": {
"taskList": {
"title": "My Tasks",
"addTask": "Add Task",
"status": {
"todo": "To Do",
"inProgress": "In Progress",
"inReview": "In Review",
"done": "Done"
}
}
}
}

View file

@ -1,5 +1,7 @@
{
"projects": "Tablos",
"home": "Aperçu",
"tablos": "Tablos",
"projects": "Aperçu",
"myEvents": "Mes Événements",
"planning": "Planning",
"tasks": "Tâches",

View file

@ -32,6 +32,15 @@
"openPlanning": "Ouvrir le planning",
"delete": "Supprimer le tablo"
},
"card": {
"progress": "Progression"
},
"projectList": {
"title": "Mes Tablos",
"seeAll": "Voir tout",
"showAll": "Voir {{count}} de plus",
"showLess": "Réduire"
},
"kpis": {
"total": "Total",
"todo": "À faire",
@ -150,5 +159,17 @@
"title": "Merci pour votre commentaire !",
"description": "Votre commentaire a été envoyé avec succès. Nous apprécions que vous ayez pris le temps de nous aider à nous améliorer."
}
},
"dashboard": {
"taskList": {
"title": "Mes Tâches",
"addTask": "Ajouter",
"status": {
"todo": "À faire",
"inProgress": "En cours",
"inReview": "En revue",
"done": "Terminé"
}
}
}
}

View file

@ -11,21 +11,15 @@ import {
EmptyHeader,
EmptyTitle,
} from "@xtablo/ui/components/empty";
// shadcn components
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip";
import { Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@xtablo/ui/components/tooltip";
import { Text } from "@xtablo/ui/components/typography";
import {
CheckCircle2,
Clock,
LayoutGrid,
List,
ListTodo,
MessageSquare,
Plus,
@ -36,13 +30,16 @@ import {
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
type FilterOption = {
id: "all" | "todo" | "inProgress" | "done";
name: string;
};
import {
useCanCreateTablo,
useCreateTablo,
useDeleteTablo,
useTablosList,
} from "../hooks/tablos";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { DashboardActionCards } from "src/components/DashboardActionCards";
import { DashboardTaskList } from "src/components/DashboardTaskList";
import { ProjectCardList } from "src/components/ProjectCardList";
export const TabloPage = () => {
const { t } = useTranslation(["pages", "common"]);
@ -57,46 +54,50 @@ export const TabloPage = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [filterType, setFilterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
const [filterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const isReadOnlyUser = useIsReadOnlyUser();
const canCreateTablo = useCanCreateTablo();
const user = useUser();
const isReadOnly = isReadOnlyUser || !canCreateTablo;
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return t("pages:tablo.greeting.morning", "Good Morning");
if (hour < 18) return t("pages:tablo.greeting.afternoon", "Good Afternoon");
return t("pages:tablo.greeting.evening", "Good Evening");
};
const formattedDate = new Intl.DateTimeFormat(undefined, {
weekday: "long",
month: "long",
day: "numeric",
}).format(new Date());
// Get view mode from URL params, default to "list"
const viewMode = (searchParams.get("view") as "grid" | "list") || "list";
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
const filterOptions: FilterOption[] = [
{ id: "all", name: t("pages:tablo.filter.all") },
{ id: "todo", name: t("pages:tablo.filter.todo") },
{ id: "inProgress", name: t("pages:tablo.filter.inProgress") },
{ id: "done", name: t("pages:tablo.filter.done") },
];
// Function to update view mode in URL
const setViewMode = (mode: "grid" | "list") => {
const newParams = new URLSearchParams(searchParams);
newParams.set("view", mode);
setSearchParams(newParams);
};
const { data: tablos, isLoading, error } = useTablosList();
const createTabloMutation = useCreateTablo();
// const { mutateAsync: updateTablo } = useUpdateTablo();
const { mutateAsync: deleteTablo } = useDeleteTablo();
// Filter tablos based on status
// Filter tablos based on status and search query
const filteredTablos = tablos?.filter((tablo) => {
if (filterType === "todo") {
return tablo.status === "todo";
} else if (filterType === "inProgress") {
return tablo.status === "inProgress";
} else if (filterType === "done") {
return tablo.status === "done";
}
return true; // 'all' case
const matchesStatus =
filterType === "all" ||
(filterType === "todo" && tablo.status === "todo") ||
(filterType === "inProgress" && tablo.status === "inProgress") ||
(filterType === "done" && tablo.status === "done");
const matchesSearch =
!searchQuery || tablo.name.toLowerCase().includes(searchQuery);
return matchesStatus && matchesSearch;
});
const menuItems = [
@ -106,7 +107,8 @@ export const TabloPage = () => {
},
{
name: "Membres",
action: (tabloId: string) => navigate(`/tablos/${tabloId}?section=members`),
action: (tabloId: string) =>
navigate(`/tablos/${tabloId}?section=members`),
},
];
@ -115,10 +117,11 @@ export const TabloPage = () => {
toast.add(
{
title: t("common:error"),
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de tablo.",
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de tablo.",
type: "error",
},
{ timeout: 5000 }
{ timeout: 5000 },
);
return;
}
@ -130,7 +133,7 @@ export const TabloPage = () => {
};
const createNewTablo = async (
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">,
) => {
try {
await createTabloMutation.mutateAsync(tabloData);
@ -199,7 +202,9 @@ export const TabloPage = () => {
};
const getUserRole = (tablo: UserTablo) => {
return tablo.is_admin ? t("pages:tablo.role.admin") : t("pages:tablo.role.guest");
return tablo.is_admin
? t("pages:tablo.role.admin")
: t("pages:tablo.role.guest");
};
const getRoleColor = (tablo: UserTablo) => {
@ -212,11 +217,14 @@ export const TabloPage = () => {
const totalTablos = tablos.length;
const todoCount = tablos.filter((t) => t.status === "todo").length;
const inProgressCount = tablos.filter((t) => t.status === "inProgress").length;
const inProgressCount = tablos.filter(
(t) => t.status === "inProgress",
).length;
const doneCount = tablos.filter((t) => t.status === "done").length;
const adminCount = tablos.filter((t) => t.is_admin).length;
const guestCount = tablos.filter((t) => !t.is_admin).length;
const completionRate = totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
const completionRate =
totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
return {
totalTablos,
@ -235,9 +243,15 @@ export const TabloPage = () => {
const isCreateDisabled = createTabloMutation.isPending || isReadOnly;
const button = (
<Button id="create-tablo-button" onClick={openCreateModal} disabled={isCreateDisabled}>
<Button
id="create-tablo-button"
onClick={openCreateModal}
disabled={isCreateDisabled}
>
<Plus />
{createTabloMutation.isPending ? t("common:actions.saving") : t("pages:tablo.createButton")}
{createTabloMutation.isPending
? t("common:actions.saving")
: t("pages:tablo.createButton")}
</Button>
);
@ -254,9 +268,15 @@ export const TabloPage = () => {
</TooltipTrigger>
<TooltipContent>
{isReadOnlyUser ? (
<p>Vous ne pouvez pas créer de tablo car vous êtes en mode lecture seule.</p>
<p>
Vous ne pouvez pas créer de tablo car vous êtes en mode lecture
seule.
</p>
) : (
<p>Vous ne pouvez pas créer de tablo car vous avez atteint votre limite de tablos.</p>
<p>
Vous ne pouvez pas créer de tablo car vous avez atteint votre
limite de tablos.
</p>
)}
</TooltipContent>
</Tooltip>
@ -271,8 +291,12 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
<h1 className="text-3xl font-bold text-foreground">
{t("pages:tablo.title")}
</h1>
<Text className="text-muted-foreground mt-1">
{t("pages:tablo.subtitle")}
</Text>
</div>
{createTabloButton()}
</div>
@ -295,8 +319,12 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
<h1 className="text-3xl font-bold text-foreground">
{t("pages:tablo.title")}
</h1>
<Text className="text-muted-foreground mt-1">
{t("pages:tablo.subtitle")}
</Text>
</div>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau tablo
@ -307,9 +335,13 @@ export const TabloPage = () => {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-center items-center min-h-64">
<div className="text-center">
<p className="text-destructive mb-2">Erreur lors du chargement des tablos</p>
<p className="text-destructive mb-2">
Erreur lors du chargement des tablos
</p>
<p className="text-muted-foreground text-sm">
{error instanceof Error ? error.message : "Une erreur inconnue s'est produite"}
{error instanceof Error
? error.message
: "Une erreur inconnue s'est produite"}
</p>
</div>
</div>
@ -335,7 +367,9 @@ export const TabloPage = () => {
>
<div
className={`bg-card rounded-lg shadow-lg transition-all duration-300 w-56 overflow-hidden border border-border ${
isAdmin ? "hover:shadow-xl cursor-pointer" : "hover:shadow-xl cursor-pointer opacity-75"
isAdmin
? "hover:shadow-xl cursor-pointer"
: "hover:shadow-xl cursor-pointer opacity-75"
}`}
onClick={(e) => {
e.stopPropagation();
@ -345,14 +379,20 @@ export const TabloPage = () => {
{/* Image or Color */}
<div className="relative h-40 group">
{tablo.image ? (
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
) : (
<div
className={`w-full h-full ${
tablo.color || "bg-gray-400"
} flex items-center justify-center`}
>
<h3 className="text-white font-bold text-xl text-center px-4">{tablo.name}</h3>
<h3 className="text-white font-bold text-xl text-center px-4">
{tablo.name}
</h3>
</div>
)}
@ -368,17 +408,21 @@ export const TabloPage = () => {
<div className="p-3">
<div className="space-y-2">
<div className="flex items-center gap-1">
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
<h3 className="text-foreground font-semibold text-base truncate">
{tablo.name}
</h3>
{/* Status badge */}
<div
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
tablo.status
tablo.status,
)} shrink-0`}
>
<span>{getStatusLabel(tablo.status)}</span>
</div>
</div>
<div className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}>
<div
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}
>
<Shield className="w-3 h-3" />
<span>{getUserRole(tablo)}</span>
</div>
@ -454,140 +498,6 @@ export const TabloPage = () => {
);
};
const renderTabloListView = (tablo: UserTablo) => {
const isAdmin = tablo.is_admin;
return (
<div
key={tablo.id}
className="relative"
data-tablo-id={tablo.id}
onContextMenu={(e) => {
e.preventDefault();
setContextMenuTablo(contextMenuTablo === tablo.id ? null : tablo.id);
setContextMenuPosition({ x: e.clientX, y: e.clientY });
}}
>
<div
className={`bg-card rounded-lg shadow-md transition-all duration-300 overflow-hidden border border-border ${
isAdmin ? "hover:shadow-lg cursor-pointer" : "hover:shadow-lg cursor-pointer opacity-75"
}`}
onClick={(e) => {
e.stopPropagation();
openTablo(tablo.id);
}}
>
<div className="flex items-center p-4 gap-4">
{/* Image or Color - smaller in list view */}
<div className="relative h-16 w-16 shrink-0 rounded-lg overflow-hidden group">
{tablo.image ? (
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
) : (
<div
className={`w-full h-full ${
tablo.color || "bg-gray-400"
} flex items-center justify-center`}
>
<span className="text-white font-bold text-sm">
{tablo.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
<div
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
tablo.status
)} shrink-0`}
>
<span>{getStatusLabel(tablo.status)}</span>
</div>
</div>
<div
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(
tablo
)} mt-1`}
>
<Shield className="w-3 h-3" />
<span>{getUserRole(tablo)}</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
{/* Quick action buttons */}
<Button
variant="outline"
size="icon"
className="p-2"
onClick={(e) => {
e.stopPropagation();
navigate(`/chat/${tablo.id}`);
}}
title="Discussions"
>
<MessageSquare className="w-5 h-5 color-foreground" />
</Button>
<Button
variant="outline"
size="icon"
className="p-2"
onClick={(e) => {
e.stopPropagation();
navigate(`/tablos/${tablo.id}?section=members`);
}}
title="Members"
>
<Users className="w-5 h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="p-2 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteTablo(tablo.id);
}}
title={t("pages:tablo.contextMenu.delete")}
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* Contextual Menu - same as grid view */}
{contextMenuTablo === tablo.id && contextMenuPosition && (
<div
className="fixed bg-card rounded-lg shadow-lg border border-border py-2 z-30 min-w-36"
style={{
left: contextMenuPosition.x,
top: contextMenuPosition.y,
}}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item, index) => (
<button
key={index}
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
item.action(tablo.id);
}}
>
<span>{item.name}</span>
</button>
))}
</div>
)}
</div>
);
};
return (
<div
className="min-h-screen"
@ -596,64 +506,18 @@ export const TabloPage = () => {
setContextMenuPosition(null);
}}
>
<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="flex justify-between items-start">
<div>
<TypographyH3>{t("pages:tablo.title")}</TypographyH3>
<TypographyMuted>{t("pages:tablo.subtitle")}</TypographyMuted>
</div>
<div className="flex items-center gap-3">
{/* Filter Controls */}
<div className="flex items-center gap-2">
<Select
value={filterType}
onValueChange={(value) =>
setFilterType(value as "all" | "todo" | "inProgress" | "done")
}
>
<SelectTrigger className="min-w-36 h-8">
<SelectValue placeholder="Filtrer" />
</SelectTrigger>
<SelectContent>
{filterOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{t(`pages:tablo.filter.${option.id}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* View Mode Toggle */}
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 border border-border">
<button
onClick={() => setViewMode("grid")}
className={`p-1.5 rounded transition-colors ${
viewMode === "grid"
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title={t("pages:tablo.view.grid")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode("list")}
className={`p-1.5 rounded transition-colors ${
viewMode === "list"
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title={t("pages:tablo.view.list")}
>
<List className="w-4 h-4" />
</button>
</div>
{createTabloButton()}
</div>
</div>
<header className="px-6 pt-6 pb-4">
<p className="text-base text-[#475467] mb-2 font-medium">
{formattedDate}
</p>
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-[24px] font-medium text-[#475467]">
{getGreeting()},{" "}
<span className="text-gray-900 font-medium">
{user.first_name ?? user.name}
</span>
!
</h1>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
@ -665,8 +529,12 @@ export const TabloPage = () => {
<div className="bg-card rounded-lg shadow-md p-4 border border-border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.totalTablos}</p>
<p className="text-sm font-medium text-muted-foreground">
Total
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.totalTablos}
</p>
</div>
<div className="p-2 bg-primary/10 rounded-lg">
<Users className="w-5 h-5 text-primary" />
@ -681,7 +549,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.todo")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.todoCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.todoCount}
</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<ListTodo className="w-5 h-5 text-muted-foreground" />
@ -713,7 +583,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.done")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.doneCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.doneCount}
</p>
</div>
<div className="p-2 bg-secondary/50 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-secondary-foreground" />
@ -745,7 +617,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.admin")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.adminCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.adminCount}
</p>
</div>
<div className="p-2 bg-primary/10 rounded-lg">
<Shield className="w-5 h-5 text-primary" />
@ -760,7 +634,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.guest")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.guestCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.guestCount}
</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<Users className="w-5 h-5 text-muted-foreground" />
@ -771,25 +647,28 @@ export const TabloPage = () => {
</div>
)}
<DashboardActionCards />
<div className="container mx-auto px-4 py-8">
{filteredTablos && filteredTablos.length > 0 ? (
viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{/* Render tablos in grid view */}
{filteredTablos.map((tablo) => renderTablo(tablo))}
</div>
) : (
<div className="flex flex-col gap-3">
{/* Render tablos in list view */}
{filteredTablos.map((tablo) => renderTabloListView(tablo))}
</div>
<ProjectCardList
tablos={filteredTablos}
onTabloClick={openTablo}
onTabloMenuClick={handleDeleteTablo}
/>
)
) : (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("pages:tablo.emptyState.title")}</EmptyTitle>
<EmptyDescription>
{filterType === "all" && t("pages:tablo.emptyState.description")}
{filterType === "all" &&
t("pages:tablo.emptyState.description")}
</EmptyDescription>
</EmptyHeader>
{filterType === "all" && (
@ -807,11 +686,16 @@ export const TabloPage = () => {
</Empty>
)}
</div>
<DashboardTaskList />
</main>
{/* Create Tablo Modal */}
{isCreateModalOpen && (
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
<CreateTabloModal
onClose={closeCreateModal}
onCreate={createNewTablo}
/>
)}
{/* Delete Tablo Modal */}

View file

@ -0,0 +1,7 @@
export function TablosPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900">Tablos</h1>
</div>
);
}