Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from "react"; | |
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Input } from "@/components/ui/input"; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow | |
| } from "@/components/ui/table"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { | |
| CalendarIcon, | |
| Clock, | |
| Loader2, | |
| Search, | |
| UserIcon, | |
| X, | |
| } from "lucide-react"; | |
| import Layout from "@/components/layout/layout"; | |
| import { AttendanceRecord } from "@/types"; | |
| import { attendanceApi } from "@/services/attendanceApi"; | |
| import { employeeService } from "@/lib/api"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| import { toast } from "sonner"; | |
| import { getConfettiOriginFromElement, triggerAttendanceConfetti } from "@/lib/confetti"; | |
| import ImportAttendanceDialog from "@/components/attendance/import-attendance-dialog"; | |
| import DownloadTemplateButton from "@/components/attendance/download-template-button"; | |
| import AttendanceParser from "@/components/attendance/attendance-parser"; | |
| import UploadAttendanceButton from "@/components/attendance/upload-attendance-button"; | |
| import { CustomSelect } from "@/components/ui/custom-select"; | |
| import { format } from "date-fns"; | |
| import { useIsMobile } from "@/hooks/use-mobile"; | |
| // Mobile Attendance Card Component | |
| interface MobileAttendanceCardProps { | |
| record: AttendanceRecord; | |
| getEmployeeName: (id: number) => string; | |
| formatTime: (time: string | null) => string; | |
| getStatusBadge: (status: string) => JSX.Element; | |
| } | |
| const MobileAttendanceCard = ({ | |
| record, | |
| getEmployeeName, | |
| formatTime, | |
| getStatusBadge, | |
| }: MobileAttendanceCardProps) => { | |
| return ( | |
| <Card className="w-full"> | |
| <CardContent className="p-4"> | |
| <div className="flex items-start justify-between mb-3"> | |
| <div> | |
| <h3 className="font-medium">{getEmployeeName(record.employeeId)}</h3> | |
| <p className="text-sm text-muted-foreground"> | |
| {format(parseAttendanceDate(record.date), "dd MMM yyyy")} | |
| </p> | |
| </div> | |
| <div>{getStatusBadge(record.status)}</div> | |
| </div> | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between text-sm"> | |
| <div className="flex items-center gap-1.5"> | |
| <Clock className="h-3.5 w-3.5 text-green-600" /> | |
| <span>In Time:</span> | |
| </div> | |
| <span className="font-medium"> | |
| {record.inTime ? ( | |
| formatTime(record.inTime) | |
| ) : ( | |
| <span className="text-muted-foreground">Not recorded</span> | |
| )} | |
| </span> | |
| </div> | |
| <div className="flex items-center justify-between text-sm"> | |
| <div className="flex items-center gap-1.5"> | |
| <Clock className="h-3.5 w-3.5 text-red-600" /> | |
| <span>Out Time:</span> | |
| </div> | |
| <span className="font-medium"> | |
| {record.outTime ? ( | |
| formatTime(record.outTime) | |
| ) : ( | |
| <span className="text-muted-foreground">Not recorded</span> | |
| )} | |
| </span> | |
| </div> | |
| <div className="flex items-center justify-between text-sm"> | |
| <div className="flex items-center gap-1.5"> | |
| <Clock className="h-3.5 w-3.5 text-blue-600" /> | |
| <span>Total Time:</span> | |
| </div> | |
| <span className="font-medium">{getOfficeDurationLabel(record.inTime, record.outTime)}</span> | |
| </div> | |
| {record.comments && ( | |
| <div className="mt-3 pt-3 border-t text-sm text-muted-foreground"> | |
| {record.comments} | |
| </div> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ); | |
| }; | |
| const months = [ | |
| "January", "February", "March", "April", "May", "June", | |
| "July", "August", "September", "October", "November", "December" | |
| ]; | |
| const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 2 + i); | |
| const parseAttendanceDate = (value: string) => { | |
| const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value); | |
| if (match) { | |
| const year = Number(match[1]); | |
| const month = Number(match[2]); | |
| const day = Number(match[3]); | |
| return new Date(year, month - 1, day); | |
| } | |
| return new Date(value); | |
| }; | |
| const parseTimeToMinutes = (value: string | null | undefined) => { | |
| if (!value) return null; | |
| const normalized = value.trim(); | |
| const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM|am|pm)?$/.exec(normalized); | |
| if (!match) return null; | |
| let hours = Number(match[1]); | |
| const minutes = Number(match[2]); | |
| const meridiem = match[4]?.toLowerCase(); | |
| if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null; | |
| if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; | |
| if (meridiem) { | |
| if (hours < 1 || hours > 12) return null; | |
| if (meridiem === "am") { | |
| hours = hours === 12 ? 0 : hours; | |
| } else { | |
| hours = hours === 12 ? 12 : hours + 12; | |
| } | |
| } | |
| return hours * 60 + minutes; | |
| }; | |
| const formatDurationMinutes = (minutes: number) => { | |
| if (!Number.isFinite(minutes) || minutes < 0) return "-"; | |
| const total = Math.floor(minutes); | |
| const h = Math.floor(total / 60); | |
| const m = total % 60; | |
| if (h > 0 && m > 0) return `${h}h ${m}m`; | |
| if (h > 0) return `${h}h`; | |
| return `${m}m`; | |
| }; | |
| const getOfficeDurationLabel = (inTime: string | null | undefined, outTime: string | null | undefined) => { | |
| const inMinutes = parseTimeToMinutes(inTime); | |
| const outMinutes = parseTimeToMinutes(outTime); | |
| if (inMinutes == null || outMinutes == null) return "-"; | |
| let diff = outMinutes - inMinutes; | |
| if (diff < 0) diff += 24 * 60; | |
| return formatDurationMinutes(diff); | |
| }; | |
| const Attendance = () => { | |
| const queryClient = useQueryClient(); | |
| const isMobile = useIsMobile(); | |
| const { userData } = useAuth(); | |
| const checkInBtnRef = React.useRef<HTMLButtonElement>(null); | |
| const checkOutBtnRef = React.useRef<HTMLButtonElement>(null); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth().toString()); | |
| const [selectedYear, setSelectedYear] = useState(new Date().getFullYear().toString()); | |
| const [selectedFile, setSelectedFile] = useState<File | null>(null); | |
| const [isParsingFile, setIsParsingFile] = useState(false); | |
| const [employeeFilter, setEmployeeFilter] = useState<string>("all"); | |
| const [statusFilter, setStatusFilter] = useState<string>("all"); | |
| const [viewMode, setViewMode] = useState<'table' | 'cards' | 'matrix'>('table'); | |
| const employeeId = userData?.employeeId; | |
| const isAttendanceAdmin = | |
| userData?.roleName === "Admin" || | |
| userData?.roleName === "CEO" || | |
| userData?.roleName === "COO" || | |
| userData?.roleName === "HR" || | |
| userData?.roleName === "HR Manager"; | |
| // Auto-switch to cards view on mobile | |
| useEffect(() => { | |
| if (isMobile && viewMode !== 'cards') { | |
| setViewMode('cards'); | |
| } | |
| }, [isMobile, viewMode]); | |
| // Fetch attendance data | |
| const { data: attendanceData, isLoading, refetch } = useQuery({ | |
| queryKey: ["attendance", employeeId ?? "all", selectedMonth, selectedYear], | |
| queryFn: async () => { | |
| const month = parseInt(selectedMonth); | |
| const year = parseInt(selectedYear); | |
| if (!isAttendanceAdmin && employeeId) { | |
| const all = await attendanceApi.getByEmployeeId(employeeId); | |
| return all.filter(r => { | |
| const d = parseAttendanceDate(r.date); | |
| return d.getFullYear() === year && d.getMonth() === month; | |
| }); | |
| } | |
| return attendanceApi.getByMonth(month, year); | |
| }, | |
| enabled: !isParsingFile, | |
| }); | |
| // Fetch employees | |
| const { data: employees, isLoading: employeesLoading } = useQuery({ | |
| queryKey: ["employees"], | |
| queryFn: () => employeeService.getAll(), | |
| enabled: isAttendanceAdmin, | |
| }); | |
| const { data: todayAttendance, isLoading: todayAttendanceLoading } = useQuery({ | |
| queryKey: ["attendance-today", employeeId ?? 0], | |
| queryFn: () => { | |
| if (!employeeId) return Promise.resolve(null); | |
| return attendanceApi.getToday(employeeId); | |
| }, | |
| enabled: !!employeeId, | |
| }); | |
| 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(checkInBtnRef.current)); | |
| await queryClient.invalidateQueries({ queryKey: ["attendance-today", employeeId ?? 0] }); | |
| await queryClient.invalidateQueries({ queryKey: ["attendance", employeeId ?? "all", selectedMonth, selectedYear] }); | |
| }, | |
| 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(checkOutBtnRef.current)); | |
| await queryClient.invalidateQueries({ queryKey: ["attendance-today", employeeId ?? 0] }); | |
| await queryClient.invalidateQueries({ queryKey: ["attendance", employeeId ?? "all", selectedMonth, selectedYear] }); | |
| }, | |
| onError: (error: any) => { | |
| toast.error(error?.response?.data || error?.message || "Failed to check out"); | |
| } | |
| }); | |
| // Handle file upload | |
| const handleFileUpload = (file: File) => { | |
| if (file) { | |
| setSelectedFile(file); | |
| setIsParsingFile(true); | |
| } | |
| }; | |
| // Cancel file parsing | |
| const handleCancelParsing = () => { | |
| setSelectedFile(null); | |
| setIsParsingFile(false); | |
| }; | |
| // Handle import complete | |
| const handleImportComplete = () => { | |
| setSelectedFile(null); | |
| setIsParsingFile(false); | |
| refetch(); | |
| toast.success("Attendance data imported successfully"); | |
| }; | |
| // Filter attendance records | |
| const filteredAttendance = React.useMemo(() => { | |
| if (!attendanceData) return []; | |
| return attendanceData.filter(record => { | |
| // Filter by employee | |
| if (employeeFilter !== "all" && record.employeeId !== parseInt(employeeFilter)) { | |
| return false; | |
| } | |
| // Filter by status | |
| if (statusFilter !== "all" && record.status !== statusFilter) { | |
| return false; | |
| } | |
| // Filter by search query (if any) | |
| if (searchQuery) { | |
| const employee = employees?.find(emp => emp.id === record.employeeId); | |
| const employeeName = employee | |
| ? `${employee.firstName} ${employee.lastName}` | |
| : `Employee ${record.employeeId}`; | |
| return employeeName.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| record.date.includes(searchQuery); | |
| } | |
| return true; | |
| }); | |
| }, [attendanceData, employeeFilter, statusFilter, searchQuery, employees]); | |
| // Get employee name by ID | |
| const getEmployeeName = (employeeId: number) => { | |
| if (!isAttendanceAdmin) { | |
| return userData ? `${userData.firstName} ${userData.lastName}` : `Employee ${employeeId}`; | |
| } | |
| const employee = employees?.find(emp => emp.id === employeeId); | |
| return employee ? `${employee.firstName} ${employee.lastName}` : `Employee ${employeeId}`; | |
| }; | |
| // Format time for display | |
| const formatTime = (time: string | null) => { | |
| if (!time) return "-"; | |
| return time; | |
| }; | |
| // Get status badge | |
| const getStatusBadge = (status: string) => { | |
| const variants: Record<string, string> = { | |
| "Present": "bg-green-100 text-green-700 border-green-200", | |
| "Absent": "bg-red-100 text-red-700 border-red-200", | |
| "Half Day": "bg-yellow-100 text-yellow-700 border-yellow-200", | |
| "Leave": "bg-blue-100 text-blue-700 border-blue-200" | |
| }; | |
| return ( | |
| <Badge variant="outline" className={variants[status] || ""}> | |
| {status} | |
| </Badge> | |
| ); | |
| }; | |
| const matrixYear = parseInt(selectedYear); | |
| const matrixMonth = parseInt(selectedMonth); | |
| const daysInSelectedMonth = new Date(matrixYear, matrixMonth + 1, 0).getDate(); | |
| const matrixDays = React.useMemo( | |
| () => Array.from({ length: daysInSelectedMonth }, (_, i) => i + 1), | |
| [daysInSelectedMonth] | |
| ); | |
| const recordByEmployeeDay = React.useMemo(() => { | |
| const map = new Map<string, AttendanceRecord>(); | |
| for (const record of attendanceData ?? []) { | |
| const d = parseAttendanceDate(record.date); | |
| if (d.getFullYear() !== matrixYear || d.getMonth() !== matrixMonth) continue; | |
| const day = d.getDate(); | |
| map.set(`${record.employeeId}-${day}`, record); | |
| } | |
| return map; | |
| }, [attendanceData, matrixYear, matrixMonth]); | |
| const matrixEmployees = React.useMemo(() => { | |
| if (!employees) return []; | |
| let list = employees; | |
| if (employeeFilter !== "all") { | |
| const selectedId = parseInt(employeeFilter); | |
| list = list.filter(e => e.id === selectedId); | |
| } | |
| if (searchQuery) { | |
| const q = searchQuery.toLowerCase(); | |
| list = list.filter(e => `${e.firstName} ${e.lastName}`.toLowerCase().includes(q)); | |
| } | |
| if (statusFilter !== "all") { | |
| const allowed = new Set<number>(); | |
| for (const record of attendanceData ?? []) { | |
| if (record.status === statusFilter) { | |
| allowed.add(record.employeeId); | |
| } | |
| } | |
| list = list.filter(e => allowed.has(e.id)); | |
| } | |
| return list; | |
| }, [employees, employeeFilter, searchQuery, statusFilter, attendanceData]); | |
| const getMatrixCellClass = (status: AttendanceRecord["status"]) => { | |
| const base = "inline-block h-2.5 w-2.5 rounded-full"; | |
| if (status === "Present") return `${base} bg-green-500`; | |
| if (status === "Absent") return `${base} bg-red-500`; | |
| if (status === "Half Day") return `${base} bg-yellow-500`; | |
| return `${base} bg-blue-500`; | |
| }; | |
| const showEmployeeFilter = isAttendanceAdmin && !employeesLoading && !!employees && employees.length > 0; | |
| const filterGridCols = showEmployeeFilter ? "lg:grid-cols-12" : "lg:grid-cols-10"; | |
| return ( | |
| <Layout> | |
| <div className="space-y-4 sm:space-y-6"> | |
| <div className="flex flex-col sm:flex-row justify-between gap-4"> | |
| <div> | |
| <h1 className="text-xl sm:text-2xl font-bold tracking-tight">Attendance Management</h1> | |
| <p className="text-sm sm:text-base text-muted-foreground"> | |
| Track and manage employee attendance records | |
| </p> | |
| </div> | |
| {isAttendanceAdmin && ( | |
| <div className="flex flex-wrap gap-2"> | |
| <DownloadTemplateButton /> | |
| <ImportAttendanceDialog onImportComplete={refetch} /> | |
| <UploadAttendanceButton onFileSelected={handleFileUpload} /> | |
| </div> | |
| )} | |
| </div> | |
| <Card> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-base">My Office Timing</CardTitle> | |
| <CardDescription className="text-xs"> | |
| {format(new Date(), "dd MMM yyyy")} | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent className="pt-0"> | |
| {todayAttendanceLoading ? ( | |
| <div className="flex items-center gap-2 text-xs text-muted-foreground"> | |
| <Loader2 className="h-3.5 w-3.5 animate-spin" /> | |
| Loading today's status... | |
| </div> | |
| ) : ( | |
| <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3"> | |
| <div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> | |
| <div> | |
| <div className="text-[11px] text-muted-foreground">In Time</div> | |
| <div className="text-sm font-medium">{todayAttendance?.inTime || "-"}</div> | |
| </div> | |
| <div> | |
| <div className="text-[11px] text-muted-foreground">Out Time</div> | |
| <div className="text-sm font-medium">{todayAttendance?.outTime || "-"}</div> | |
| </div> | |
| <div className="col-span-2 sm:col-span-1"> | |
| <div className="text-[11px] text-muted-foreground">Status</div> | |
| <div className="text-sm font-medium"> | |
| {todayAttendance?.outTime | |
| ? "Checked Out" | |
| : todayAttendance?.inTime | |
| ? "Checked In" | |
| : "Not Checked In"} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <Button | |
| ref={checkInBtnRef} | |
| size="sm" | |
| onClick={() => checkInMutation.mutate()} | |
| disabled={ | |
| !employeeId || | |
| checkInMutation.isPending || | |
| !!todayAttendance?.inTime | |
| } | |
| className="h-8 px-3 text-xs" | |
| > | |
| {checkInMutation.isPending ? ( | |
| <> | |
| <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> | |
| Checking In... | |
| </> | |
| ) : ( | |
| "Check In" | |
| )} | |
| </Button> | |
| <Button | |
| ref={checkOutBtnRef} | |
| variant="outline" | |
| size="sm" | |
| onClick={() => checkOutMutation.mutate()} | |
| disabled={ | |
| !employeeId || | |
| checkOutMutation.isPending || | |
| !todayAttendance?.inTime || | |
| !!todayAttendance?.outTime | |
| } | |
| className="h-8 px-3 text-xs" | |
| > | |
| {checkOutMutation.isPending ? ( | |
| <> | |
| <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> | |
| Checking Out... | |
| </> | |
| ) : ( | |
| "Check Out" | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {isParsingFile && selectedFile && ( | |
| <AttendanceParser | |
| file={selectedFile} | |
| onComplete={handleImportComplete} | |
| onCancel={handleCancelParsing} | |
| /> | |
| )} | |
| {!isParsingFile && ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle>Attendance Records</CardTitle> | |
| <CardDescription> | |
| View and filter employee attendance records | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-2"> | |
| <div className={`grid grid-cols-1 sm:grid-cols-2 ${filterGridCols} gap-2 items-end`}> | |
| <div className="lg:col-span-4 flex items-center gap-2"> | |
| <Search className="h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="flex-1 h-8 text-xs" | |
| /> | |
| </div> | |
| <div className="lg:col-span-2"> | |
| <label className="sr-only">Month</label> | |
| <CustomSelect | |
| value={selectedMonth} | |
| onChange={setSelectedMonth} | |
| placeholder="Month" | |
| options={months.map((month, index) => ({ value: index.toString(), label: month }))} | |
| triggerClassName="h-8 px-2 py-1 text-xs" | |
| /> | |
| </div> | |
| <div className="lg:col-span-2"> | |
| <label className="sr-only">Year</label> | |
| <CustomSelect | |
| value={selectedYear} | |
| onChange={setSelectedYear} | |
| placeholder="Year" | |
| options={years.map((year) => ({ value: year.toString(), label: year.toString() }))} | |
| triggerClassName="h-8 px-2 py-1 text-xs" | |
| /> | |
| </div> | |
| <div className="lg:col-span-2"> | |
| <label className="sr-only">Status</label> | |
| <CustomSelect | |
| value={statusFilter} | |
| onChange={setStatusFilter} | |
| placeholder="Status" | |
| options={[ | |
| { value: "all", label: "All Statuses" }, | |
| { value: "Present", label: "Present" }, | |
| { value: "Absent", label: "Absent" }, | |
| { value: "Half Day", label: "Half Day" }, | |
| { value: "Leave", label: "Leave" }, | |
| ]} | |
| triggerClassName="h-8 px-2 py-1 text-xs" | |
| /> | |
| </div> | |
| {showEmployeeFilter && ( | |
| <div className="lg:col-span-2"> | |
| <label className="sr-only">Employee</label> | |
| <CustomSelect | |
| value={employeeFilter} | |
| onChange={setEmployeeFilter} | |
| placeholder="Employee" | |
| options={[ | |
| { value: "all", label: "All Employees" }, | |
| ...employees!.map((employee) => ({ | |
| value: employee.id.toString(), | |
| label: `${employee.firstName} ${employee.lastName}`, | |
| })), | |
| ]} | |
| triggerClassName="h-8 px-2 py-1 text-xs" | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {!isMobile && ( | |
| <div className="flex justify-end"> | |
| <div className="flex items-center gap-2 bg-gray-100 rounded-lg p-1"> | |
| <Button | |
| variant={viewMode === 'table' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setViewMode('table')} | |
| className="h-8 px-3" | |
| > | |
| Table | |
| </Button> | |
| <Button | |
| variant={viewMode === 'cards' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setViewMode('cards')} | |
| className="h-8 px-3" | |
| > | |
| Cards | |
| </Button> | |
| {isAttendanceAdmin && ( | |
| <Button | |
| variant={viewMode === 'matrix' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setViewMode('matrix')} | |
| className="h-8 px-3" | |
| > | |
| Matrix | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <div className="rounded-md border overflow-hidden"> | |
| {isLoading ? ( | |
| <div className="flex justify-center items-center h-24 sm:h-32"> | |
| <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div> | |
| <span className="ml-2">Loading attendance data...</span> | |
| </div> | |
| ) : (!isMobile && viewMode === 'matrix' && isAttendanceAdmin) ? ( | |
| <div className="max-h-[600px] overflow-auto"> | |
| {employeesLoading ? ( | |
| <div className="flex justify-center items-center h-24 sm:h-32"> | |
| <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div> | |
| <span className="ml-2">Loading employees...</span> | |
| </div> | |
| ) : !employees || employees.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center h-32 sm:h-48"> | |
| <UserIcon className="h-10 w-10 text-muted-foreground/50 mb-2" /> | |
| <p className="text-muted-foreground">No employees found</p> | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="flex items-center gap-4 px-4 py-2 border-b text-xs text-muted-foreground"> | |
| <div className="flex items-center gap-2"> | |
| <span className={getMatrixCellClass("Present")} /> | |
| <span>Present</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className={getMatrixCellClass("Absent")} /> | |
| <span>Absent</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className={getMatrixCellClass("Half Day")} /> | |
| <span>Half Day</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className={getMatrixCellClass("Leave")} /> | |
| <span>Leave</span> | |
| </div> | |
| </div> | |
| <table className="min-w-max w-full border-collapse"> | |
| <thead className="sticky top-0 z-30 bg-background"> | |
| <tr> | |
| <th className="sticky left-0 z-40 bg-background border-b border-r px-3 py-2 text-left text-sm font-medium min-w-[220px]"> | |
| Employee | |
| </th> | |
| {matrixDays.map(day => ( | |
| <th | |
| key={day} | |
| className="border-b px-2 py-2 text-xs font-medium text-muted-foreground text-center min-w-[34px]" | |
| > | |
| {day} | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {matrixEmployees.map(emp => ( | |
| <tr key={emp.id} className="hover:bg-muted/30"> | |
| <td className="sticky left-0 z-20 bg-background border-r px-3 py-2 text-sm font-medium whitespace-nowrap"> | |
| {emp.firstName} {emp.lastName} | |
| </td> | |
| {matrixDays.map(day => { | |
| const record = recordByEmployeeDay.get(`${emp.id}-${day}`); | |
| const cellRecord = record && (statusFilter === "all" || record.status === statusFilter) ? record : null; | |
| const title = cellRecord | |
| ? `${cellRecord.status}${cellRecord.inTime ? ` | In: ${cellRecord.inTime}` : ""}${cellRecord.outTime ? ` | Out: ${cellRecord.outTime}` : ""} | Total: ${getOfficeDurationLabel(cellRecord.inTime, cellRecord.outTime)}` | |
| : ""; | |
| return ( | |
| <td key={day} className="border-b px-2 py-2 text-center"> | |
| {cellRecord ? ( | |
| <span className={getMatrixCellClass(cellRecord.status)} title={title} /> | |
| ) : ( | |
| <span className="text-muted-foreground/40">·</span> | |
| )} | |
| </td> | |
| ); | |
| })} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </> | |
| )} | |
| </div> | |
| ) : filteredAttendance.length > 0 ? ( | |
| <> | |
| {(isMobile || viewMode === 'cards') && ( | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4"> | |
| {filteredAttendance.map((record, index) => ( | |
| <MobileAttendanceCard | |
| key={`${record.employeeId}-${record.date}-${index}`} | |
| record={record} | |
| getEmployeeName={getEmployeeName} | |
| formatTime={formatTime} | |
| getStatusBadge={getStatusBadge} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {!isMobile && viewMode === 'table' && ( | |
| <div className="max-h-[600px] overflow-auto"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead>Employee</TableHead> | |
| <TableHead>Date</TableHead> | |
| <TableHead>In Time</TableHead> | |
| <TableHead>Out Time</TableHead> | |
| <TableHead>Total Time</TableHead> | |
| <TableHead>Status</TableHead> | |
| <TableHead>Comments</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {filteredAttendance.map((record, index) => ( | |
| <TableRow key={`${record.employeeId}-${record.date}-${index}`}> | |
| <TableCell className="font-medium"> | |
| {getEmployeeName(record.employeeId)} | |
| </TableCell> | |
| <TableCell> | |
| {format(parseAttendanceDate(record.date), "dd MMM yyyy")} | |
| </TableCell> | |
| <TableCell> | |
| {record.inTime ? ( | |
| <div className="flex items-center gap-1.5"> | |
| <Clock className="h-3.5 w-3.5 text-green-600" /> | |
| {formatTime(record.inTime)} | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-1.5 text-muted-foreground"> | |
| <X className="h-3.5 w-3.5" /> | |
| Not recorded | |
| </div> | |
| )} | |
| </TableCell> | |
| <TableCell> | |
| {record.outTime ? ( | |
| <div className="flex items-center gap-1.5"> | |
| <Clock className="h-3.5 w-3.5 text-red-600" /> | |
| {formatTime(record.outTime)} | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-1.5 text-muted-foreground"> | |
| <X className="h-3.5 w-3.5" /> | |
| Not recorded | |
| </div> | |
| )} | |
| </TableCell> | |
| <TableCell> | |
| {getOfficeDurationLabel(record.inTime, record.outTime)} | |
| </TableCell> | |
| <TableCell> | |
| {getStatusBadge(record.status)} | |
| </TableCell> | |
| <TableCell className="text-muted-foreground text-sm"> | |
| {record.comments || "-"} | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| </> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center h-32 sm:h-48"> | |
| <CalendarIcon className="h-10 w-10 text-muted-foreground/50 mb-2" /> | |
| <p className="text-muted-foreground">No attendance records found</p> | |
| <p className="text-sm text-muted-foreground/80 mt-1"> | |
| Try changing filters or importing attendance data | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| {viewMode !== 'matrix' && filteredAttendance.length > 0 && ( | |
| <div className="flex items-center justify-between text-sm text-muted-foreground pt-2 border-t"> | |
| <div> | |
| <span className="font-medium">{filteredAttendance.length}</span> records found | |
| </div> | |
| <div> | |
| <Button variant="ghost" size="sm" onClick={() => { | |
| setSearchQuery(""); | |
| setEmployeeFilter("all"); | |
| setStatusFilter("all"); | |
| }}> | |
| Clear filters | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </div> | |
| </Layout> | |
| ); | |
| }; | |
| export default Attendance; |