o134's picture
Upload full TeamTasker system with all fixes
8314cf4 verified
Raw
History Blame Contribute Delete
17.4 kB
import { ReactNode, useState, useRef, useEffect } from "react";
import { Link, useLocation } from "wouter";
import { useAuth } from "../context/auth-context";
import {
useListNotifications,
useMarkNotificationRead,
useMarkAllNotificationsRead,
getListNotificationsQueryKey,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import {
LayoutDashboard,
CheckSquare,
Users2,
Bell,
Users,
Building2,
ShieldCheck,
X,
ExternalLink,
LogOut,
Menu,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
// Role helpers
function isAdmin(role: string) { return role === "admin"; }
function isHeadOfTeam(role: string) { return role === "head_of_team"; }
function isTeamLead(role: string) { return role === "team_lead"; }
function isMember(role: string) { return role === "member"; }
function canManageUsers(role: string) { return isAdmin(role) || isHeadOfTeam(role) || isTeamLead(role); }
function getNavLinks(role: string) {
const roles_all = ["admin", "head_of_team", "team_lead", "member"];
const roles_privileged = ["admin", "head_of_team", "team_lead"];
const links = [
{ href: "/", label: "لوحة التحكم", icon: LayoutDashboard, roles: roles_all },
{ href: "/tasks", label: "المهام", icon: CheckSquare, roles: roles_all },
{ href: "/clients", label: "العملاء", icon: Building2, roles: roles_privileged },
{ href: "/notifications", label: "الإشعارات", icon: Bell, roles: roles_all },
{ href: "/users", label: "الأعضاء", icon: Users, roles: roles_privileged },
{ href: "/teams", label: "الفرق", icon: Users2, roles: ["admin"] },
{ href: "/admin", label: "الإدارة", icon: ShieldCheck, roles: ["admin"] },
];
return links.filter(l => l.roles.includes(role));
}
function getMobileTabs(role: string) {
const roles_all = ["admin", "head_of_team", "team_lead", "member"];
const roles_privileged = ["admin", "head_of_team", "team_lead"];
const all = [
{ href: "/", label: "الرئيسية", icon: LayoutDashboard, roles: roles_all },
{ href: "/tasks", label: "المهام", icon: CheckSquare, roles: roles_all },
{ href: "/clients", label: "العملاء", icon: Building2, roles: roles_privileged },
{ href: "/users", label: "الأعضاء", icon: Users, roles: roles_privileged },
{ href: "/admin", label: "الإدارة", icon: ShieldCheck, roles: ["admin"] },
];
return all.filter(l => l.roles.includes(role)).slice(0, 5);
}
function NotificationsDropdown({ userId }: { userId: number }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: notifications } = useListNotifications(
{ userId, unreadOnly: false },
{ query: { enabled: !!userId, queryKey: getListNotificationsQueryKey({ userId, unreadOnly: false }) } }
);
const { data: unreadNotifs } = useListNotifications(
{ userId, unreadOnly: true },
{ query: { enabled: !!userId, queryKey: getListNotificationsQueryKey({ userId, unreadOnly: true }) } }
);
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const unreadCount = unreadNotifs?.length ?? 0;
const recent = notifications?.slice(0, 12) ?? [];
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
const interval = setInterval(() => {
queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey({ userId, unreadOnly: true }) });
}, 60_000);
return () => clearInterval(interval);
}, [queryClient, userId]);
const handleMarkRead = (id: number) => {
markRead.mutate({ id }, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey({ userId, unreadOnly: true }) });
queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey({ userId, unreadOnly: false }) });
},
});
};
const handleMarkAllRead = () => {
markAllRead.mutate({ data: { userId } }, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey({ userId, unreadOnly: true }) });
queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey({ userId, unreadOnly: false }) });
toast({ title: "تم تعليم جميع الإشعارات كمقروءة" });
setOpen(false);
},
});
};
const notifTypeIcon: Record<string, string> = {
task_assigned: "📋",
status_changed: "🔄",
new_comment: "💬",
comment_added: "💬",
deadline_soon: "⏰",
};
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(o => !o)}
className="relative p-2 rounded-lg hover:bg-slate-100 transition-colors"
aria-label="الإشعارات"
>
<Bell className="h-5 w-5 text-slate-600" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute left-0 top-full mt-2 w-96 max-w-[calc(100vw-2rem)] bg-white border rounded-xl shadow-2xl z-50 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b bg-slate-50">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-slate-500" />
<span className="font-semibold text-sm">الإشعارات</span>
{unreadCount > 0 && (
<Badge className="bg-red-100 text-red-700 text-[10px] px-1.5 py-0 hover:bg-red-100">{unreadCount} جديد</Badge>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button onClick={handleMarkAllRead} className="text-xs text-primary hover:underline font-medium">
تعليم الكل كمقروء
</button>
)}
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-slate-200 transition-colors ml-1">
<X className="h-3.5 w-3.5 text-slate-500" />
</button>
</div>
</div>
<div className="max-h-80 overflow-y-auto">
{recent.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="text-4xl mb-2"></div>
<p className="text-sm font-medium text-slate-600">أنت محدّث!</p>
<p className="text-xs text-slate-400 mt-1">لا توجد إشعارات جديدة</p>
</div>
) : (
recent.map(n => (
<div
key={n.id}
className={`flex items-start gap-3 px-4 py-3 border-b last:border-0 hover:bg-slate-50 transition-colors ${!n.isRead ? "bg-blue-50/40" : ""}`}
>
<span className="text-lg flex-shrink-0 mt-0.5">{notifTypeIcon[n.type] ?? "🔔"}</span>
<div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${!n.isRead ? "font-medium text-slate-800" : "text-slate-600"}`}>
{n.message}
</p>
<p className="text-[10px] text-slate-400 mt-1">
{new Date(n.createdAt).toLocaleDateString("ar", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{n.taskId && (
<Link href={`/tasks/${n.taskId}`} onClick={() => setOpen(false)}>
<button className="p-1 rounded hover:bg-slate-200 transition-colors" title="عرض المهمة">
<ExternalLink className="h-3 w-3 text-slate-400" />
</button>
</Link>
)}
{!n.isRead && (
<button
onClick={() => handleMarkRead(n.id)}
className="w-2 h-2 rounded-full bg-blue-500 hover:bg-blue-700 transition-colors flex-shrink-0 mt-1"
title="تعليم كمقروء"
/>
)}
</div>
</div>
))
)}
</div>
<div className="border-t px-4 py-2 bg-slate-50">
<Link href="/notifications" onClick={() => setOpen(false)}>
<button className="text-xs text-primary hover:underline w-full text-center">
عرض جميع الإشعارات
</button>
</Link>
</div>
</div>
)}
</div>
);
}
function RoleBadge({ role }: { role: string }) {
const labels: Record<string, { label: string; cls: string }> = {
admin: { label: "مدير النظام", cls: "bg-red-100 text-red-700" },
head_of_team: { label: "رئيس الفرق", cls: "bg-orange-100 text-orange-700" },
team_lead: { label: "قائد فريق", cls: "bg-blue-100 text-blue-700" },
member: { label: "عضو", cls: "bg-slate-100 text-slate-600" },
};
const r = labels[role] ?? { label: role, cls: "bg-slate-100 text-slate-500" };
return (
<span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded-full ${r.cls}`}>{r.label}</span>
);
}
function Header({ onMenuToggle }: { onMenuToggle: () => void }) {
const { user, logout } = useAuth();
return (
<header className="h-16 border-b bg-white flex items-center justify-between px-4 md:px-6 flex-shrink-0">
<div className="flex items-center gap-3">
<button
onClick={onMenuToggle}
className="md:hidden p-2 rounded-lg hover:bg-slate-100 transition-colors"
aria-label="القائمة"
>
<Menu className="h-5 w-5 text-slate-600" />
</button>
<h1 className="font-bold text-xl text-primary tracking-tight">TeamTasker</h1>
<Badge variant="outline" className="text-xs bg-slate-50 text-slate-500 font-normal hidden sm:flex">Command Center</Badge>
</div>
<div className="flex items-center gap-2 md:gap-3">
{user && <NotificationsDropdown userId={user.id} />}
{user && (
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-2">
<Avatar className="h-8 w-8 border border-slate-200">
<AvatarFallback style={{ backgroundColor: user.avatarColor || "#ccc", color: "#fff", fontSize: "12px" }}>
{user.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="hidden lg:flex flex-col gap-0.5">
<span className="text-sm font-medium text-slate-800 leading-none">{user.name}</span>
<div className="flex items-center gap-1">
<span className="text-xs text-slate-500">{user.team}</span>
<RoleBadge role={user.role} />
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={logout}
className="text-slate-500 hover:text-red-600 hover:bg-red-50 gap-1.5"
title="تسجيل الخروج"
>
<LogOut className="h-4 w-4" />
<span className="hidden md:inline text-sm">خروج</span>
</Button>
</div>
)}
</div>
</header>
);
}
function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
const [location] = useLocation();
const { user, logout } = useAuth();
const navLinks = user ? getNavLinks(user.role) : [];
return (
<>
{open && (
<div className="fixed inset-0 bg-black/30 z-20 md:hidden" onClick={onClose} />
)}
<aside
className={`
fixed md:relative z-30 md:z-auto
top-0 md:top-auto right-0 md:right-auto
h-full md:h-auto
w-64 border-r bg-slate-50/95 md:bg-slate-50/50 flex flex-col flex-shrink-0
transition-transform duration-300 md:translate-x-0
${open ? "translate-x-0" : "translate-x-full md:translate-x-0"}
`}
>
<div className="flex items-center justify-between p-4 border-b md:hidden">
<span className="font-bold text-primary">TeamTasker</span>
<button onClick={onClose} className="p-1 rounded hover:bg-slate-200">
<X className="h-4 w-4 text-slate-500" />
</button>
</div>
{user && (
<div className="p-4 border-b hidden md:block">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 border border-slate-200 shadow-sm">
<AvatarFallback style={{ backgroundColor: user.avatarColor || "#ccc", color: "#fff" }}>
{user.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 gap-0.5">
<span className="text-sm font-medium truncate">{user.name}</span>
<span className="text-xs text-slate-500 truncate">{user.team}</span>
<RoleBadge role={user.role} />
</div>
</div>
</div>
)}
<div className="p-4 flex-1 overflow-y-auto">
<nav className="space-y-1">
{navLinks.map(link => {
const Icon = link.icon;
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
return (
<Link
key={link.href}
href={link.href}
onClick={onClose}
className={`flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
isActive ? "bg-primary/10 text-primary" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
}`}
>
<Icon className={`h-4 w-4 ${isActive ? "text-primary" : "text-slate-400"}`} />
{link.label}
</Link>
);
})}
</nav>
</div>
<div className="p-4 border-t">
<button
onClick={() => { logout(); onClose(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-md transition-colors"
>
<LogOut className="h-4 w-4" />
تسجيل الخروج
</button>
</div>
</aside>
</>
);
}
function MobileBottomBar() {
const [location] = useLocation();
const { user } = useAuth();
const tabs = user ? getMobileTabs(user.role) : [];
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-20 bg-white border-t shadow-lg">
<div className="flex items-center justify-around h-16 px-2">
{tabs.map(tab => {
const Icon = tab.icon;
const isActive = location === tab.href || (tab.href !== "/" && location.startsWith(tab.href));
return (
<Link
key={tab.href}
href={tab.href}
className={`flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg transition-colors ${
isActive ? "text-primary" : "text-slate-400"
}`}
>
<Icon className="h-5 w-5" />
<span className="text-[9px] font-medium">{tab.label}</span>
</Link>
);
})}
</div>
</nav>
);
}
export function AppLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen flex flex-col bg-background text-foreground h-screen overflow-hidden" dir="rtl">
<Header onMenuToggle={() => setSidebarOpen(o => !o)} />
<div className="flex flex-1 overflow-hidden">
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="flex-1 overflow-auto bg-slate-50/30 pb-16 md:pb-0">
{children}
</main>
</div>
<MobileBottomBar />
</div>
);
}
// Export role helpers for use in other components
export { isAdmin, isHeadOfTeam, isTeamLead, isMember, canManageUsers };