pmtool / src /components /attendance /attendance-parser.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
19.1 kB
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;