Spaces:
Sleeping
Sleeping
| 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 }; | |