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 (

{getEmployeeName(record.employeeId)}

{format(parseAttendanceDate(record.date), "dd MMM yyyy")}

{getStatusBadge(record.status)}
In Time:
{record.inTime ? ( formatTime(record.inTime) ) : ( Not recorded )}
Out Time:
{record.outTime ? ( formatTime(record.outTime) ) : ( Not recorded )}
Total Time:
{getOfficeDurationLabel(record.inTime, record.outTime)}
{record.comments && (
{record.comments}
)}
); }; 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(null); const checkOutBtnRef = React.useRef(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(null); const [isParsingFile, setIsParsingFile] = useState(false); const [employeeFilter, setEmployeeFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("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 = { "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 ( {status} ); }; 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(); 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(); 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 (

Attendance Management

Track and manage employee attendance records

{isAttendanceAdmin && (
)}
My Office Timing {format(new Date(), "dd MMM yyyy")} {todayAttendanceLoading ? (
Loading today's status...
) : (
In Time
{todayAttendance?.inTime || "-"}
Out Time
{todayAttendance?.outTime || "-"}
Status
{todayAttendance?.outTime ? "Checked Out" : todayAttendance?.inTime ? "Checked In" : "Not Checked In"}
)}
{isParsingFile && selectedFile && ( )} {!isParsingFile && ( Attendance Records View and filter employee attendance records
setSearchQuery(e.target.value)} className="flex-1 h-8 text-xs" />
({ value: index.toString(), label: month }))} triggerClassName="h-8 px-2 py-1 text-xs" />
({ value: year.toString(), label: year.toString() }))} triggerClassName="h-8 px-2 py-1 text-xs" />
{showEmployeeFilter && (
({ value: employee.id.toString(), label: `${employee.firstName} ${employee.lastName}`, })), ]} triggerClassName="h-8 px-2 py-1 text-xs" />
)}
{!isMobile && (
{isAttendanceAdmin && ( )}
)}
{isLoading ? (
Loading attendance data...
) : (!isMobile && viewMode === 'matrix' && isAttendanceAdmin) ? (
{employeesLoading ? (
Loading employees...
) : !employees || employees.length === 0 ? (

No employees found

) : ( <>
Present
Absent
Half Day
Leave
{matrixDays.map(day => ( ))} {matrixEmployees.map(emp => ( {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 ( ); })} ))}
Employee {day}
{emp.firstName} {emp.lastName} {cellRecord ? ( ) : ( ยท )}
)}
) : filteredAttendance.length > 0 ? ( <> {(isMobile || viewMode === 'cards') && (
{filteredAttendance.map((record, index) => ( ))}
)} {!isMobile && viewMode === 'table' && (
Employee Date In Time Out Time Total Time Status Comments {filteredAttendance.map((record, index) => ( {getEmployeeName(record.employeeId)} {format(parseAttendanceDate(record.date), "dd MMM yyyy")} {record.inTime ? (
{formatTime(record.inTime)}
) : (
Not recorded
)}
{record.outTime ? (
{formatTime(record.outTime)}
) : (
Not recorded
)}
{getOfficeDurationLabel(record.inTime, record.outTime)} {getStatusBadge(record.status)} {record.comments || "-"}
))}
)} ) : (

No attendance records found

Try changing filters or importing attendance data

)}
{viewMode !== 'matrix' && filteredAttendance.length > 0 && (
{filteredAttendance.length} records found
)}
)}
); }; export default Attendance;