Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from "react"; | |
| import { Bell, ChevronDown, Menu, Search, X, Download, LogIn, LogOut, Loader2 } from "lucide-react"; | |
| import { useNavigate, useLocation } from "react-router-dom"; | |
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuLabel, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; | |
| import Logo from "@/components/ui/logo"; | |
| import { useIsMobile } from "@/hooks/use-mobile"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; | |
| import { useAssetRequestNotifications } from "@/hooks/useAssetRequestNotifications"; | |
| import { attendanceApi } from "@/services/attendanceApi"; | |
| import { getConfettiOriginFromElement, triggerAttendanceConfetti } from "@/lib/confetti"; | |
| import CheckOutReminderDialog from "@/components/CheckOutReminderDialog"; | |
| interface HeaderProps { | |
| toggleSidebar: () => void; | |
| isSidebarOpen: boolean; | |
| } | |
| const Header = ({ toggleSidebar, isSidebarOpen }: HeaderProps) => { | |
| const isMobile = useIsMobile(); | |
| const [isSearchOpen, setIsSearchOpen] = useState(false); | |
| const [userMenuOpen, setUserMenuOpen] = useState(false); | |
| const [downloadMenuOpen, setDownloadMenuOpen] = useState(false); | |
| const [now, setNow] = useState(() => new Date()); | |
| const [showCheckOutReminder, setShowCheckOutReminder] = useState(false); | |
| const [reminderDismissed, setReminderDismissed] = useState(false); | |
| const quickAttendanceBtnRef = useRef<HTMLButtonElement>(null); | |
| const userMenuRef = useRef<HTMLDivElement>(null); | |
| const downloadMenuRef = useRef<HTMLDivElement>(null); | |
| const { logout, userData, isAuthenticated } = useAuth(); | |
| const queryClient = useQueryClient(); | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const { notificationCount, hasNotifications } = useAssetRequestNotifications(); | |
| // Check if we're on the download app page | |
| const isDownloadPage = location.pathname === "/download-app"; | |
| const employeeId = userData?.employeeId; | |
| const isClient = userData?.roleName?.toLowerCase() === "client"; | |
| const { data: todayAttendance, isLoading: todayAttendanceLoading } = useQuery({ | |
| queryKey: ["attendance-today", employeeId ?? 0], | |
| queryFn: () => { | |
| if (!employeeId) return Promise.resolve(null); | |
| return attendanceApi.getToday(employeeId); | |
| }, | |
| enabled: isAuthenticated && !isDownloadPage && !isClient && !!employeeId, | |
| refetchInterval: 60_000, | |
| }); | |
| const invalidateAttendance = async () => { | |
| if (employeeId) { | |
| await queryClient.invalidateQueries({ queryKey: ["attendance-today", employeeId] }); | |
| } | |
| await queryClient.invalidateQueries({ queryKey: ["attendance"] }); | |
| }; | |
| const checkInMutation = useMutation({ | |
| mutationFn: async () => { | |
| if (!employeeId) throw new Error("Employee ID not found"); | |
| return attendanceApi.checkIn(employeeId); | |
| }, | |
| onSuccess: async () => { | |
| toast.success("Checked in successfully"); | |
| triggerAttendanceConfetti("checkIn", getConfettiOriginFromElement(quickAttendanceBtnRef.current)); | |
| await invalidateAttendance(); | |
| }, | |
| onError: (error: any) => { | |
| toast.error(error?.response?.data || error?.message || "Failed to check in"); | |
| }, | |
| }); | |
| const checkOutMutation = useMutation({ | |
| mutationFn: async () => { | |
| if (!employeeId) throw new Error("Employee ID not found"); | |
| return attendanceApi.checkOut(employeeId); | |
| }, | |
| onSuccess: async () => { | |
| toast.success("Checked out successfully"); | |
| triggerAttendanceConfetti("checkOut", getConfettiOriginFromElement(quickAttendanceBtnRef.current)); | |
| await invalidateAttendance(); | |
| }, | |
| onError: (error: any) => { | |
| toast.error(error?.response?.data || error?.message || "Failed to check out"); | |
| }, | |
| }); | |
| const isCheckedIn = !!todayAttendance?.inTime; | |
| const isCheckedOut = !!todayAttendance?.outTime; | |
| const canCheckIn = !todayAttendanceLoading && !checkInMutation.isPending && !isCheckedIn; | |
| const canCheckOut = !todayAttendanceLoading && !checkOutMutation.isPending && isCheckedIn && !isCheckedOut; | |
| const showQuickAttendance = isAuthenticated && !isDownloadPage && !isClient && !!employeeId; | |
| useEffect(() => { | |
| if (!isAuthenticated || isDownloadPage || !isCheckedIn || isCheckedOut) { | |
| setNow(new Date()); | |
| return; | |
| } | |
| const intervalId = window.setInterval(() => setNow(new Date()), 1000); | |
| return () => window.clearInterval(intervalId); | |
| }, [isAuthenticated, isDownloadPage, isCheckedIn, isCheckedOut]); | |
| const parseTimeToToday = (time: string) => { | |
| const parts = time.split(":").map(p => parseInt(p, 10)); | |
| const [hh, mm, ss] = [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; | |
| const dt = new Date(now); | |
| dt.setHours(hh, mm, ss, 0); | |
| return dt; | |
| }; | |
| const officeDurationText = (() => { | |
| if (!todayAttendance?.inTime) return null; | |
| const start = parseTimeToToday(todayAttendance.inTime); | |
| const end = todayAttendance.outTime ? parseTimeToToday(todayAttendance.outTime) : now; | |
| const diffMs = Math.max(0, end.getTime() - start.getTime()); | |
| const totalSeconds = Math.floor(diffMs / 1000); | |
| const hours = Math.floor(totalSeconds / 3600); | |
| const minutes = Math.floor((totalSeconds % 3600) / 60); | |
| const seconds = totalSeconds % 60; | |
| const pad2 = (n: number) => n.toString().padStart(2, "0"); | |
| return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`; | |
| })(); | |
| // Handle navigation to home/dashboard | |
| const goToHomePage = () => { | |
| if (isAuthenticated) { | |
| // For client role, redirect to production-bugs page instead of dashboard | |
| if (userData?.roleName?.toLowerCase() === "client") { | |
| navigate('/production-bugs'); | |
| } else { | |
| // For other roles, redirect to dashboard | |
| navigate('/'); | |
| } | |
| } else { | |
| // For non-authenticated users, redirect to login | |
| navigate('/login'); | |
| } | |
| }; | |
| // Check if it's past 6:30 PM and show checkout reminder | |
| useEffect(() => { | |
| if (!isAuthenticated || isDownloadPage || isClient || !isCheckedIn || isCheckedOut || reminderDismissed) { | |
| return; | |
| } | |
| const currentHour = now.getHours(); | |
| const currentMinute = now.getMinutes(); | |
| // Check if it's 6:30 PM or later (18:30 in 24-hour format) | |
| const isPast630PM = currentHour > 18 || (currentHour === 18 && currentMinute >= 30); | |
| if (isPast630PM && !showCheckOutReminder) { | |
| setShowCheckOutReminder(true); | |
| } | |
| }, [now, isAuthenticated, isDownloadPage, isClient, isCheckedIn, isCheckedOut, reminderDismissed, showCheckOutReminder]); | |
| // Handle outside clicks to close the menus | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) { | |
| setUserMenuOpen(false); | |
| } | |
| if (downloadMenuRef.current && !downloadMenuRef.current.contains(event.target as Node)) { | |
| setDownloadMenuOpen(false); | |
| } | |
| }; | |
| if (userMenuOpen || downloadMenuOpen) { | |
| document.addEventListener('mousedown', handleClickOutside); | |
| document.addEventListener('touchstart', handleClickOutside as any); | |
| } | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| document.removeEventListener('touchstart', handleClickOutside as any); | |
| }; | |
| }, [userMenuOpen, downloadMenuOpen]); | |
| const handleLogout = () => { | |
| logout(); | |
| toast.success("Logged out successfully"); | |
| navigate('/login'); | |
| }; | |
| const handleReminderClose = () => { | |
| setShowCheckOutReminder(false); | |
| setReminderDismissed(true); | |
| }; | |
| const handleReminderCheckOut = async () => { | |
| await checkOutMutation.mutateAsync(); | |
| setShowCheckOutReminder(false); | |
| }; | |
| const toggleUserMenu = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setUserMenuOpen(prev => !prev); | |
| }; | |
| const toggleDownloadMenu = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setDownloadMenuOpen(prev => !prev); | |
| }; | |
| // Get initials for avatar fallback | |
| const getInitials = () => { | |
| if (!userData) return "U"; | |
| const first = userData.firstName?.charAt(0) || ''; | |
| const last = userData.lastName?.charAt(0) || ''; | |
| return (first + last).toUpperCase(); | |
| }; | |
| // Get display name | |
| const getDisplayName = () => { | |
| if (!userData) return "User"; | |
| return `${userData.firstName} ${userData.lastName}`; | |
| }; | |
| // Generate avatar URL | |
| const getAvatarUrl = () => { | |
| if (!userData) return ''; | |
| return `https://ui-avatars.com/api/?name=${userData.firstName}+${userData.lastName}&background=random`; | |
| }; | |
| // Download handlers | |
| const handleDownloadAPK = () => { | |
| window.open('/pm-tool.apk', '_blank'); | |
| setDownloadMenuOpen(false); | |
| }; | |
| const handleVisitDownloadPage = () => { | |
| navigate('/download-app'); | |
| setDownloadMenuOpen(false); | |
| }; | |
| return ( | |
| <header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-lg border-b h-16 flex items-center justify-between px-4 transition-all duration-300"> | |
| <div className="flex items-center gap-4"> | |
| {isMobile && isAuthenticated && ( | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={toggleSidebar} | |
| aria-label="Toggle sidebar" | |
| > | |
| {isSidebarOpen ? ( | |
| <X className="h-5 w-5" /> | |
| ) : ( | |
| <Menu className="h-5 w-5" /> | |
| )} | |
| </Button> | |
| )} | |
| {(!isMobile || !isSearchOpen) && ( | |
| <div | |
| className="cursor-pointer" | |
| onClick={goToHomePage} | |
| title="Go to Dashboard" | |
| > | |
| <Logo /> | |
| </div> | |
| )} | |
| </div> | |
| <div className={`flex-1 flex ${isMobile ? "justify-end" : "justify-center"} max-w-xl mx-auto`}> | |
| {!isDownloadPage && isAuthenticated && (isSearchOpen || !isMobile) && ( | |
| <div className={`relative w-full max-w-md ${isMobile ? "absolute left-0 right-0 px-4" : ""}`}> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> | |
| <Input | |
| placeholder="Search..." | |
| className="w-full pl-9 bg-secondary/50 border-secondary-foreground/10 focus-visible:ring-primary/20" | |
| /> | |
| {isMobile && ( | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="absolute right-5 top-1/2 transform -translate-y-1/2" | |
| onClick={() => setIsSearchOpen(false)} | |
| > | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| </div> | |
| )} | |
| {isMobile && !isSearchOpen && !isDownloadPage && isAuthenticated && ( | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={() => setIsSearchOpen(true)} | |
| > | |
| <Search className="h-5 w-5" /> | |
| </Button> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| {/* Custom Download Menu Implementation */} | |
| <div ref={downloadMenuRef} className="relative"> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="text-primary hover:text-primary/80" | |
| onClick={toggleDownloadMenu} | |
| > | |
| <Download className="h-5 w-5" /> | |
| </Button> | |
| {downloadMenuOpen && ( | |
| <div className="absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-popover border border-border z-50"> | |
| <div className="py-2 px-4 border-b"> | |
| <p className="font-medium">Download Options</p> | |
| </div> | |
| <div className="py-1"> | |
| <button | |
| className="flex w-full px-4 py-2 text-sm hover:bg-accent" | |
| onClick={handleDownloadAPK} | |
| > | |
| Download APK Directly | |
| </button> | |
| <button | |
| className="flex w-full px-4 py-2 text-sm hover:bg-accent" | |
| onClick={handleVisitDownloadPage} | |
| > | |
| Visit Download Page | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {isAuthenticated ? ( | |
| <> | |
| {!isDownloadPage && !!officeDurationText && ( | |
| <div className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs sm:text-sm text-muted-foreground"> | |
| <span className="text-muted-foreground">In Office</span> | |
| <span className="font-medium text-foreground tabular-nums">{officeDurationText}</span> | |
| </div> | |
| )} | |
| {showQuickAttendance && ( | |
| <TooltipProvider> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button | |
| ref={quickAttendanceBtnRef} | |
| variant="outline" | |
| size={isMobile ? "icon" : "sm"} | |
| className={isMobile ? "" : "gap-2"} | |
| disabled={!canCheckIn && !canCheckOut} | |
| onClick={() => { | |
| if (canCheckIn) { | |
| checkInMutation.mutate(); | |
| return; | |
| } | |
| if (canCheckOut) { | |
| checkOutMutation.mutate(); | |
| } | |
| }} | |
| > | |
| {(todayAttendanceLoading || checkInMutation.isPending || checkOutMutation.isPending) ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : canCheckIn ? ( | |
| <LogIn className="h-4 w-4" /> | |
| ) : canCheckOut ? ( | |
| <LogOut className="h-4 w-4" /> | |
| ) : ( | |
| <LogOut className="h-4 w-4" /> | |
| )} | |
| {!isMobile && ( | |
| <span> | |
| {canCheckIn ? "Check In" : canCheckOut ? "Check Out" : "Done"} | |
| </span> | |
| )} | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p> | |
| {canCheckIn | |
| ? "Quick Check In" | |
| : canCheckOut | |
| ? "Quick Check Out" | |
| : "Attendance completed for today"} | |
| </p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </TooltipProvider> | |
| )} | |
| <TooltipProvider> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="relative" | |
| onClick={() => { | |
| const isAdmin = userData?.roleId === 1 || userData?.roleName?.toLowerCase() === 'admin'; | |
| navigate(isAdmin ? '/admin/asset-requests' : '/asset-requests'); | |
| }} | |
| > | |
| <Bell className="h-5 w-5" /> | |
| {hasNotifications && ( | |
| <> | |
| <span className="absolute top-1 right-1 w-2 h-2 bg-primary rounded-full" /> | |
| {notificationCount > 0 && ( | |
| <span className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium"> | |
| {notificationCount > 9 ? '9+' : notificationCount} | |
| </span> | |
| )} | |
| </> | |
| )} | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p> | |
| {hasNotifications | |
| ? `${notificationCount} asset request ${notificationCount === 1 ? 'notification' : 'notifications'}` | |
| : 'No new notifications'} | |
| </p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </TooltipProvider> | |
| {/* Custom User Menu Implementation */} | |
| <div ref={userMenuRef} className="relative"> | |
| <Button | |
| variant="ghost" | |
| className="flex items-center gap-2" | |
| onClick={toggleUserMenu} | |
| > | |
| <Avatar className="h-8 w-8"> | |
| <AvatarImage src={getAvatarUrl()} /> | |
| <AvatarFallback>{getInitials()}</AvatarFallback> | |
| </Avatar> | |
| {!isMobile && ( | |
| <> | |
| <div className="flex flex-col items-start text-sm"> | |
| <span className="font-medium">{getDisplayName()}</span> | |
| <span className="text-xs text-muted-foreground"> | |
| {userData?.roleName || "User"} | |
| </span> | |
| </div> | |
| <ChevronDown className="h-4 w-4 text-muted-foreground" /> | |
| </> | |
| )} | |
| </Button> | |
| {userMenuOpen && ( | |
| <div className="absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-popover border border-border z-50"> | |
| <div className="py-2 px-4 border-b"> | |
| <p className="font-medium">My Account</p> | |
| </div> | |
| <div className="py-1"> | |
| <button | |
| className="flex w-full px-4 py-2 text-sm hover:bg-accent" | |
| onClick={() => { | |
| navigate('/profile'); | |
| setUserMenuOpen(false); | |
| }} | |
| > | |
| Profile | |
| </button> | |
| <button | |
| className="flex w-full px-4 py-2 text-sm hover:bg-accent" | |
| onClick={() => { | |
| navigate('/settings'); | |
| setUserMenuOpen(false); | |
| }} | |
| > | |
| Settings | |
| </button> | |
| </div> | |
| <div className="border-t py-1"> | |
| <button | |
| className="flex w-full px-4 py-2 text-sm hover:bg-accent" | |
| onClick={() => { | |
| handleLogout(); | |
| setUserMenuOpen(false); | |
| }} | |
| > | |
| Log out | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="flex items-center gap-3"> | |
| <Button | |
| variant="default" | |
| size="sm" | |
| onClick={() => navigate('/login')} | |
| className="font-medium" | |
| > | |
| Log In | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Check-Out Reminder Dialog */} | |
| <CheckOutReminderDialog | |
| isOpen={showCheckOutReminder} | |
| onClose={handleReminderClose} | |
| onCheckOut={handleReminderCheckOut} | |
| isLoading={checkOutMutation.isPending} | |
| userName={userData?.firstName || 'User'} | |
| workDuration={officeDurationText || '00:00:00'} | |
| /> | |
| </header> | |
| ); | |
| }; | |
| export default Header; | |