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:
parent
4347adedd9
commit
a3f5cf5e4e
15 changed files with 1163 additions and 286 deletions
77
apps/main/src/components/ActionCard.tsx
Normal file
77
apps/main/src/components/ActionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/main/src/components/DashboardActionCards.tsx
Normal file
62
apps/main/src/components/DashboardActionCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
apps/main/src/components/DashboardTaskList.tsx
Normal file
191
apps/main/src/components/DashboardTaskList.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
|
|
|
|||
157
apps/main/src/components/ProjectCard.tsx
Normal file
157
apps/main/src/components/ProjectCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/main/src/components/ProjectCardList.tsx
Normal file
78
apps/main/src/components/ProjectCardList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
apps/main/src/components/TopBar.tsx
Normal file
362
apps/main/src/components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"projects": "Tablos",
|
||||
"home": "Home",
|
||||
"tablos": "Tablos",
|
||||
"projects": "Home",
|
||||
"myEvents": "My Events",
|
||||
"planning": "Planning",
|
||||
"tasks": "Tasks",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"projects": "Tablos",
|
||||
"home": "Aperçu",
|
||||
"tablos": "Tablos",
|
||||
"projects": "Aperçu",
|
||||
"myEvents": "Mes Événements",
|
||||
"planning": "Planning",
|
||||
"tasks": "Tâches",
|
||||
|
|
|
|||
|
|
@ -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é"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
7
apps/main/src/pages/tablos.tsx
Normal file
7
apps/main/src/pages/tablos.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue