Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from '@/components/ui/table'; | |
| import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { AlertCircle, Check, Loader2, Save, X } from 'lucide-react'; | |
| import { attendanceApi } from '@/services/attendanceApi'; | |
| import { toast } from 'sonner'; | |
| import * as XLSX from 'xlsx'; | |
| import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; | |
| import { AttendanceRecord } from '@/types'; | |
| interface ParsedAttendance { | |
| employeeId: string; | |
| employeeName: string; | |
| date: string; | |
| inTime: string | null; | |
| outTime: string | null; | |
| } | |
| interface PunchRecord { | |
| employeeId: string; | |
| firstName: string; | |
| date: string; | |
| punchTime: string; | |
| punchState: string; | |
| deviceName: string; | |
| isInRecord: boolean; | |
| } | |
| interface ProcessedAttendance { | |
| employeeId: string; | |
| firstName: string; | |
| date: string; | |
| firstIn: string | null; | |
| lastOut: string | null; | |
| totalHours: string; | |
| } | |
| interface AttendanceParserProps { | |
| file: File; | |
| onComplete: () => void; | |
| onCancel: () => void; | |
| } | |
| const AttendanceParser: React.FC<AttendanceParserProps> = ({ file, onComplete, onCancel }) => { | |
| const [parsedData, setParsedData] = useState<AttendanceRecord[]>([]); | |
| const [processedData, setProcessedData] = useState<ProcessedAttendance[]>([]); | |
| const [errors, setErrors] = useState<string[]>([]); | |
| const [saving, setSaving] = useState(false); | |
| const [parsing, setParsing] = useState(true); | |
| const [showAllRecords, setShowAllRecords] = useState(false); | |
| useEffect(() => { | |
| parseExcelFile(); | |
| }, [file]); | |
| const parseExcelFile = async () => { | |
| setParsing(true); | |
| try { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const data = new Uint8Array(e.target?.result as ArrayBuffer); | |
| const workbook = XLSX.read(data, { type: 'array' }); | |
| // Get the first sheet | |
| const firstSheetName = workbook.SheetNames[0]; | |
| const worksheet = workbook.Sheets[firstSheetName]; | |
| // Convert to JSON | |
| const jsonData = XLSX.utils.sheet_to_json(worksheet); | |
| const validationErrors: string[] = []; | |
| const punchRecords: PunchRecord[] = []; | |
| // First, parse all punch records | |
| jsonData.forEach((row: any, index) => { | |
| try { | |
| if (!row['Employee ID'] || !row['Date'] || !row['Punch Time']) { | |
| validationErrors.push(`Row ${index + 2}: Missing required fields (Employee ID, Date, or Punch Time)`); | |
| return; | |
| } | |
| // Handle Employee ID as string or number | |
| const employeeId = String(row['Employee ID']).trim(); | |
| if (!employeeId) { | |
| validationErrors.push(`Row ${index + 2}: Empty Employee ID`); | |
| return; | |
| } | |
| // Parse date | |
| let date: string; | |
| try { | |
| if (typeof row['Date'] === 'string') { | |
| // Try to parse string date | |
| const dateParts = row['Date'].split(/[-\/]/); | |
| if (dateParts.length === 3) { | |
| // Assuming format is DD-MM-YYYY or MM-DD-YYYY or YYYY-MM-DD | |
| const dateObj = new Date(row['Date']); | |
| if (isNaN(dateObj.getTime())) { | |
| throw new Error('Invalid date format'); | |
| } | |
| date = dateObj.toISOString().split('T')[0]; | |
| } else { | |
| throw new Error('Invalid date format'); | |
| } | |
| } else if (typeof row['Date'] === 'number') { | |
| // Excel date (serial number) | |
| const excelDateValue = row['Date']; | |
| const dateObj = XLSX.SSF.parse_date_code(excelDateValue); | |
| const jsDate = new Date(dateObj.y, dateObj.m - 1, dateObj.d); | |
| date = jsDate.toISOString().split('T')[0]; | |
| } else { | |
| throw new Error('Invalid date format'); | |
| } | |
| } catch (error) { | |
| validationErrors.push(`Row ${index + 2}: Invalid date format`); | |
| return; | |
| } | |
| // Determine if this is an IN or OUT record based on Device Name or Punch State | |
| const deviceName = row['Device Name'] || ''; | |
| const punchState = row['Punch State'] || ''; | |
| let isInRecord = false; | |
| if (deviceName.includes('Syns-IN') || deviceName.includes('Syn-IN')) { | |
| isInRecord = true; | |
| } else if (deviceName.includes('Syns-OUT') || deviceName.includes('Syn-OUT')) { | |
| isInRecord = false; | |
| } else if (punchState === 'Check Out') { | |
| isInRecord = false; | |
| } else { | |
| // If no clear indicator, assume it's an IN record | |
| isInRecord = true; | |
| } | |
| // Create punch record | |
| const record: PunchRecord = { | |
| employeeId, | |
| firstName: row['First Name'] || '', | |
| date, | |
| punchTime: row['Punch Time'] || '', | |
| punchState, | |
| deviceName, | |
| isInRecord | |
| }; | |
| punchRecords.push(record); | |
| } catch (error) { | |
| validationErrors.push(`Row ${index + 2}: ${(error as Error).message}`); | |
| } | |
| }); | |
| // Process punch records to get first IN and last OUT for each employee by date | |
| const processedRecords = processAttendanceRecords(punchRecords); | |
| setProcessedData(processedRecords); | |
| // Convert to AttendanceRecord format for API | |
| const attendanceRecords: AttendanceRecord[] = processedRecords.map(record => ({ | |
| id: 0, // ID will be assigned by the server | |
| employeeId: parseInt(record.employeeId) || 0, // Convert back to number for API | |
| date: record.date, | |
| inTime: record.firstIn, | |
| outTime: record.lastOut, | |
| status: 'Present', // Default to 'Present' if we have punch records | |
| comments: `Total Hours: ${record.totalHours}` | |
| })); | |
| setParsedData(attendanceRecords); | |
| setErrors(validationErrors); | |
| } catch (error) { | |
| console.error('Error parsing Excel file:', error); | |
| setErrors(['Could not parse Excel file. Please check the file format.']); | |
| } finally { | |
| setParsing(false); | |
| } | |
| }; | |
| reader.onerror = () => { | |
| setErrors(['Error reading file']); | |
| setParsing(false); | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| } catch (error) { | |
| console.error('Error processing file:', error); | |
| setErrors(['Error processing file']); | |
| setParsing(false); | |
| } | |
| }; | |
| // Calculate hours between two time strings | |
| const calculateHours = (inTime: string | null, outTime: string | null): string => { | |
| if (!inTime || !outTime) return '-'; | |
| try { | |
| // Parse time strings (assuming format like "09:30:00") | |
| const [inHours, inMinutes, inSeconds] = inTime.split(':').map(Number); | |
| const [outHours, outMinutes, outSeconds] = outTime.split(':').map(Number); | |
| // Convert to minutes for easier calculation | |
| const inTotalMinutes = inHours * 60 + inMinutes + (inSeconds || 0) / 60; | |
| const outTotalMinutes = outHours * 60 + outMinutes + (outSeconds || 0) / 60; | |
| // Calculate difference | |
| let diffMinutes = outTotalMinutes - inTotalMinutes; | |
| // Handle case where employee left next day (after midnight) | |
| if (diffMinutes < 0) { | |
| diffMinutes += 24 * 60; // Add 24 hours | |
| } | |
| // Convert back to hours and minutes | |
| const hours = Math.floor(diffMinutes / 60); | |
| const minutes = Math.round(diffMinutes % 60); | |
| return `${hours}h ${minutes}m`; | |
| } catch (error) { | |
| console.error('Error calculating hours:', error); | |
| return '-'; | |
| } | |
| }; | |
| // Process attendance records to get first IN and last OUT for each employee by date | |
| const processAttendanceRecords = (punchRecords: PunchRecord[]): ProcessedAttendance[] => { | |
| // Group records by employeeId and date | |
| const groupedRecords: Record<string, PunchRecord[]> = {}; | |
| punchRecords.forEach(record => { | |
| const key = `${record.employeeId}-${record.date}`; | |
| if (!groupedRecords[key]) { | |
| groupedRecords[key] = []; | |
| } | |
| groupedRecords[key].push(record); | |
| }); | |
| // Process each group to find first IN and last OUT | |
| const result: ProcessedAttendance[] = []; | |
| Object.keys(groupedRecords).forEach(key => { | |
| const records = groupedRecords[key]; | |
| // Sort records by punch time | |
| records.sort((a, b) => { | |
| return new Date(a.date + 'T' + a.punchTime).getTime() - | |
| new Date(b.date + 'T' + b.punchTime).getTime(); | |
| }); | |
| // Find first IN | |
| const inRecords = records.filter(r => r.isInRecord); | |
| const firstInRecord = inRecords.length > 0 ? inRecords[0] : null; | |
| // Find last OUT | |
| const outRecords = records.filter(r => !r.isInRecord); | |
| const lastOutRecord = outRecords.length > 0 ? outRecords[outRecords.length - 1] : null; | |
| // Calculate total hours | |
| const totalHours = calculateHours( | |
| firstInRecord?.punchTime || null, | |
| lastOutRecord?.punchTime || null | |
| ); | |
| // Add to result | |
| result.push({ | |
| employeeId: records[0].employeeId, | |
| firstName: records[0].firstName, | |
| date: records[0].date, | |
| firstIn: firstInRecord ? firstInRecord.punchTime : null, | |
| lastOut: lastOutRecord ? lastOutRecord.punchTime : null, | |
| totalHours | |
| }); | |
| }); | |
| // Sort by date and then by employee ID | |
| result.sort((a, b) => { | |
| const dateComparison = a.date.localeCompare(b.date); | |
| if (dateComparison !== 0) return dateComparison; | |
| // Try to sort by employee ID numerically if possible | |
| const empIdA = parseInt(a.employeeId); | |
| const empIdB = parseInt(b.employeeId); | |
| if (!isNaN(empIdA) && !isNaN(empIdB)) { | |
| return empIdA - empIdB; | |
| } | |
| // Fall back to string comparison | |
| return a.employeeId.localeCompare(b.employeeId); | |
| }); | |
| return result; | |
| }; | |
| const handleSave = async () => { | |
| if (parsedData.length === 0) { | |
| toast.error('No valid data to save'); | |
| return; | |
| } | |
| setSaving(true); | |
| try { | |
| await attendanceApi.uploadBulk({ | |
| month: 'Auto-detected', | |
| year: new Date().getFullYear(), | |
| records: parsedData | |
| }); | |
| toast.success(`Successfully imported ${parsedData.length} attendance records`); | |
| onComplete(); | |
| } catch (error) { | |
| console.error('Error saving attendance data:', error); | |
| toast.error('Failed to save attendance data'); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| // Export to Excel | |
| const handleExport = () => { | |
| try { | |
| // Create workbook and worksheet | |
| const wb = XLSX.utils.book_new(); | |
| // Format data for export | |
| const exportData = processedData.map(record => ({ | |
| 'Date': record.date, | |
| 'Employee ID': record.employeeId, | |
| 'Name': record.firstName, | |
| 'First IN': record.firstIn || '-', | |
| 'Last OUT': record.lastOut || '-', | |
| 'Total Hours': record.totalHours | |
| })); | |
| // Create worksheet from data | |
| const ws = XLSX.utils.json_to_sheet(exportData); | |
| // Add worksheet to workbook | |
| XLSX.utils.book_append_sheet(wb, ws, 'Attendance'); | |
| // Generate Excel file and trigger download | |
| XLSX.writeFile(wb, 'Attendance_Summary.xlsx'); | |
| toast.success('Excel file exported successfully'); | |
| } catch (error) { | |
| console.error('Error exporting to Excel:', error); | |
| toast.error('Failed to export Excel file'); | |
| } | |
| }; | |
| // 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> | |
| ); | |
| }; | |
| // Toggle between showing all records and only 100 | |
| const toggleShowAllRecords = () => { | |
| setShowAllRecords(prev => !prev); | |
| }; | |
| // Get records to display based on showAllRecords flag | |
| const getDisplayRecords = () => { | |
| return showAllRecords ? processedData : processedData.slice(0, 100); | |
| }; | |
| return ( | |
| <Card className="w-full"> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2"> | |
| {parsing && <Loader2 className="h-5 w-5 animate-spin text-primary" />} | |
| {!parsing && processedData.length > 0 && errors.length === 0 && <Check className="h-5 w-5 text-green-500" />} | |
| {!parsing && errors.length > 0 && <AlertCircle className="h-5 w-5 text-amber-500" />} | |
| Attendance File: {file.name} | |
| </CardTitle> | |
| <CardDescription> | |
| {parsing ? ( | |
| 'Parsing file, please wait...' | |
| ) : ( | |
| <> | |
| {processedData.length} records found | |
| {errors.length > 0 && ` with ${errors.length} validation errors`} | |
| </> | |
| )} | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| {parsing ? ( | |
| <div className="flex flex-col items-center justify-center py-12"> | |
| <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> | |
| <p className="text-muted-foreground text-center"> | |
| Processing file... This may take a moment. | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| {/* Display validation errors */} | |
| {errors.length > 0 && ( | |
| <Alert variant="destructive"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertTitle>Validation Errors</AlertTitle> | |
| <AlertDescription> | |
| <div className="mt-2 max-h-40 overflow-y-auto text-sm"> | |
| <ul className="list-disc pl-5 space-y-1"> | |
| {errors.map((error, index) => ( | |
| <li key={index}>{error}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| </AlertDescription> | |
| </Alert> | |
| )} | |
| {/* Button controls */} | |
| {processedData.length > 0 && ( | |
| <div className="flex justify-between items-center"> | |
| <div className="flex gap-2"> | |
| {processedData.length > 100 && ( | |
| <Button | |
| variant="outline" | |
| onClick={toggleShowAllRecords} | |
| size="sm" | |
| > | |
| {showAllRecords ? "Show First 100" : "Show All Records"} | |
| </Button> | |
| )} | |
| <Button | |
| variant="outline" | |
| onClick={handleExport} | |
| size="sm" | |
| > | |
| Export to Excel | |
| </Button> | |
| </div> | |
| <div className="text-sm text-muted-foreground"> | |
| Showing {getDisplayRecords().length} of {processedData.length} records | |
| </div> | |
| </div> | |
| )} | |
| {/* Preview table */} | |
| {processedData.length > 0 && ( | |
| <div className="border rounded-md overflow-auto max-h-96"> | |
| <Table> | |
| <TableHeader className="sticky top-0 bg-background"> | |
| <TableRow> | |
| <TableHead>Date</TableHead> | |
| <TableHead>Employee ID</TableHead> | |
| <TableHead>Name</TableHead> | |
| <TableHead>First IN</TableHead> | |
| <TableHead>Last OUT</TableHead> | |
| <TableHead>Total Hours</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {getDisplayRecords().map((record, index) => ( | |
| <TableRow key={index}> | |
| <TableCell>{record.date}</TableCell> | |
| <TableCell>{record.employeeId}</TableCell> | |
| <TableCell>{record.firstName}</TableCell> | |
| <TableCell>{record.firstIn || '-'}</TableCell> | |
| <TableCell>{record.lastOut || '-'}</TableCell> | |
| <TableCell>{record.totalHours}</TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| {processedData.length === 0 && !parsing && ( | |
| <div className="text-center py-8"> | |
| <AlertCircle className="h-12 w-12 text-amber-500 mx-auto mb-2" /> | |
| <h3 className="text-lg font-medium">No valid data found</h3> | |
| <p className="text-muted-foreground mt-1"> | |
| The file doesn't contain any valid attendance records. | |
| Please check the file format and try again. | |
| </p> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </CardContent> | |
| <CardFooter className="flex justify-end gap-2"> | |
| <Button variant="outline" onClick={onCancel} disabled={saving}> | |
| Cancel | |
| </Button> | |
| <Button | |
| onClick={handleSave} | |
| disabled={parsedData.length === 0 || saving || parsing} | |
| className="flex items-center gap-2" | |
| > | |
| {saving && <Loader2 className="h-4 w-4 animate-spin" />} | |
| {saving ? 'Saving...' : 'Import Data'} | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| ); | |
| }; | |
| export default AttendanceParser; |