import { Head, router } from '@inertiajs/react'; import { addMonths, format, subMonths } from 'date-fns'; import { id } from 'date-fns/locale'; import { ChevronLeft, ChevronRight, Clock, LogIn, LogOut, } from 'lucide-react'; import { useMemo, useState, useCallback } from 'react'; import { toast } from 'sonner'; import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert'; import { CameraCapture, LocationMap } from '@/components/inputs'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { byMonth as attendanceByMonth, store, update, } from '@/routes/admin/hr/attendances'; type Attendance = { id: number; employee_id: number; attendance_date: string; check_in_at: string | null; check_out_at: string | null; check_in_photo: string | null; check_out_photo: string | null; check_in_latitude: number; check_in_longitude: number; check_out_latitude: number | null; check_out_longitude: number | null; work_duration_minutes: number | null; employee_name: string; }; type Leave = { id: number; employee_id: number; start_date: string; end_date: string; total_days: number; status: string; employee_name: string; }; type Employee = { id: number; name: string; }; type Props = { attendances: Attendance[]; leaves: Leave[]; employees?: Employee[]; todayAttendance: Attendance | null; currentYear: number; currentMonth: number; hrSettings: { scheduled_check_in_time: string; scheduled_check_out_time: string; }; isOnLeave: boolean; canCheckIn: boolean; isAdmin: boolean; highlight?: number; }; const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab']; function getDaysInMonth(year: number, month: number): number { return new Date(year, month, 0).getDate(); } function getFirstDayOfMonth(year: number, month: number): number { return new Date(year, month - 1, 1).getDay(); } function isSameDay(d1: Date, d2: Date): boolean { return ( d1.getDate() === d2.getDate() && d1.getMonth() === d2.getMonth() && d1.getFullYear() === d2.getFullYear() ); } function isWeekend(date: Date): boolean { const day = date.getDay(); return day === 0 || day === 6; } function isLate( checkInAt: string | null, officeHour: number, officeMinute: number, ): boolean { if (!checkInAt) { return false; } const d = new Date(checkInAt); const h = d.getHours(); const m = d.getMinutes(); return h > officeHour || (h === officeHour && m > officeMinute); } function getLateMinutes( checkInAt: string | null, officeHour: number, officeMinute: number, ): number { if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) { return 0; } const d = new Date(checkInAt); const officeStart = new Date(d); officeStart.setHours(officeHour, officeMinute, 0, 0); return Math.ceil((d.getTime() - officeStart.getTime()) / 60000); } function formatMinutes(minutes: number | null): string { if (!minutes) { return '-'; } const hours = Math.floor(minutes / 60); const mins = Math.floor(minutes % 60); if (mins === 0) { return `${hours} jam`; } return `${hours} jam ${mins} menit`; } export default function AttendanceIndex({ attendances: initialAttendances, leaves: initialLeaves, employees: initialEmployees = [], todayAttendance, currentYear, currentMonth, hrSettings, isOnLeave, canCheckIn, isAdmin, highlight, }: Props) { const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time .split(':') .map(Number); const [viewDate, setViewDate] = useState( new Date(currentYear, currentMonth - 1), ); const [selectedDate, setSelectedDate] = useState(new Date()); const [showCamera, setShowCamera] = useState(false); const [actionType, setActionType] = useState<'check-in' | 'check-out'>( 'check-in', ); const [locationLoading, setLocationLoading] = useState(false); const [detailAttendance, setDetailAttendance] = useState( null, ); const [attendances, setAttendances] = useState(initialAttendances); const [leaves, setLeaves] = useState(initialLeaves); const [employees, setEmployees] = useState(initialEmployees); const [loadingMonth, setLoadingMonth] = useState(false); const viewYear = viewDate.getFullYear(); const viewMonth = viewDate.getMonth() + 1; const fetchMonthData = useCallback(async (year: number, month: number) => { setLoadingMonth(true); try { const url = attendanceByMonth.url({ query: { year, month } }); const res = await fetch(url); if (res.ok) { const data = await res.json(); setAttendances(data.attendances); setLeaves(data.leaves); setEmployees(data.employees); } } finally { setLoadingMonth(false); } }, []); const attendanceByDate = useMemo(() => { const map = new Map(); attendances.forEach((att) => { const existing = map.get(att.attendance_date) ?? []; existing.push(att); map.set(att.attendance_date, existing); }); return map; }, [attendances]); const leavesByDate = useMemo(() => { const map = new Map(); leaves.forEach((leave) => { const start = new Date(leave.start_date); const end = new Date(leave.end_date); const current = new Date(start); while (current <= end) { const dateStr = format(current, 'yyyy-MM-dd'); const existing = map.get(dateStr) ?? []; existing.push(leave); map.set(dateStr, existing); current.setDate(current.getDate() + 1); } }); return map; }, [leaves]); const calendarDays = useMemo(() => { const daysInMonth = getDaysInMonth(viewYear, viewMonth); const firstDay = getFirstDayOfMonth(viewYear, viewMonth); const prevMonthDays = getDaysInMonth( viewYear, viewMonth === 1 ? 12 : viewMonth - 1, ); const days: { day: number; isCurrentMonth: boolean; date: Date }[] = []; for (let i = firstDay - 1; i >= 0; i--) { const d = prevMonthDays - i; const m = viewMonth === 1 ? 12 : viewMonth - 1; const y = viewMonth === 1 ? viewYear - 1 : viewYear; days.push({ day: d, isCurrentMonth: false, date: new Date(y, m - 1, d), }); } for (let i = 1; i <= daysInMonth; i++) { days.push({ day: i, isCurrentMonth: true, date: new Date(viewYear, viewMonth - 1, i), }); } const remaining = 42 - days.length; for (let i = 1; i <= remaining; i++) { const m = viewMonth === 12 ? 1 : viewMonth + 1; const y = viewMonth === 12 ? viewYear + 1 : viewYear; days.push({ day: i, isCurrentMonth: false, date: new Date(y, m - 1, i), }); } return days; }, [viewYear, viewMonth]); const handlePrevMonth = () => { const newDate = subMonths(viewDate, 1); setViewDate(newDate); fetchMonthData(newDate.getFullYear(), newDate.getMonth() + 1); }; const handleNextMonth = () => { const newDate = addMonths(viewDate, 1); setViewDate(newDate); fetchMonthData(newDate.getFullYear(), newDate.getMonth() + 1); }; const handleGoToToday = () => { const now = new Date(); setViewDate(now); setSelectedDate(now); fetchMonthData(now.getFullYear(), now.getMonth() + 1); }; const handleCameraCapture = (dataUrl: string) => { setShowCamera(false); setLocationLoading(true); if (!navigator.geolocation) { setLocationLoading(false); toast.error('Geolocation tidak didukung di browser ini.'); return; } navigator.geolocation.getCurrentPosition( (position) => { setLocationLoading(false); const formData = new FormData(); formData.append('photo', dataUrl); formData.append( 'latitude', position.coords.latitude.toString(), ); formData.append( 'longitude', position.coords.longitude.toString(), ); if (actionType === 'check-in') { router.post(store(), formData, { preserveScroll: true }); } else if (actionType === 'check-out' && todayAttendance) { router.put(update(todayAttendance.id), formData, { preserveScroll: true, }); } }, () => { setLocationLoading(false); toast.error( 'Gagal mendapatkan lokasi. Pastikan izin lokasi diberikan.', ); }, { enableHighAccuracy: true, timeout: 10000 }, ); }; const handleCheckIn = () => { setActionType('check-in'); setShowCamera(true); }; const handleCheckOut = () => { setActionType('check-out'); setShowCamera(true); }; function formatTime(dateStr: string | null): string { if (!dateStr) { return '-'; } return format(new Date(dateStr), 'HH:mm'); } const today = new Date(); return ( <>

Presensi

{highlight && (

Menampilkan presensi dari notifikasi.

)}
{!isAdmin && ( )}
{format(today, 'MMM', { locale: id, })}
{format(today, 'dd')}

{format(viewDate, 'MMMM yyyy', { locale: id, })}

{isAdmin && (
Hadir Terlambat Belum Pulang Cuti
)}
{WEEKDAYS.map((day) => (
{day}
))}
{calendarDays.map((cell, idx) => { const dateStr = format(cell.date, 'yyyy-MM-dd'); const dayAttendances = attendanceByDate.get(dateStr) ?? []; const dayLeaves = leavesByDate.get(dateStr) ?? []; const isSelected = isSameDay( cell.date, selectedDate, ); const isTodayDate = isSameDay( cell.date, new Date(), ); const todayMidnight = new Date(); todayMidnight.setHours(0, 0, 0, 0); const cellDate = new Date(cell.date); cellDate.setHours(0, 0, 0, 0); const isPastDate = cellDate < todayMidnight; const isFutureDate = cellDate > todayMidnight; const showAbsent = cell.isCurrentMonth && isPastDate && !isWeekend(cell.date) && dayAttendances.length === 0 && dayLeaves.length === 0; return (
{ setSelectedDate(cell.date); if (!isAdmin && dayAttendances.length > 0) { setDetailAttendance(dayAttendances[0]); } }} className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${!cell.isCurrentMonth ? 'bg-muted/30 text-muted-foreground/50' : '' } ${isSelected ? 'bg-muted/50' : ''} ${!isAdmin && dayAttendances.length > 0 ? 'cursor-pointer' : '' }`} style={{ borderRight: '1px solid var(--border)', borderBottom: '1px solid var(--border)', }} >
{cell.day}
{isAdmin ? ( <> {cell.isCurrentMonth && !isFutureDate && [...employees].sort((a, b) => a.name.localeCompare(b.name)).map((emp) => { const att = dayAttendances.find((a) => a.employee_id === emp.id); const leave = dayLeaves.find((l) => l.employee_id === emp.id); if (att) { const late = isLate( att.check_in_at, officeHour, officeMinute, ); const notCheckedOut = !att.check_out_at; return ( { e.stopPropagation(); setDetailAttendance(att); }} > {emp.name} ); } if (leave) { return ( {emp.name} ); } if (cell.isCurrentMonth && !isFutureDate) { return ( {emp.name} ); } return null; })} ) : ( <> {dayAttendances.length > 0 && (() => { const att = dayAttendances[0]; const late = isLate( att.check_in_at, officeHour, officeMinute, ); const lateMins = getLateMinutes( att.check_in_at, officeHour, officeMinute, ); return ( <> {late ? 'Terlambat' : 'Hadir'} {!att.check_out_at && ( Belum Pulang )} Masuk :{' '} {formatTime( att.check_in_at, )} Pulang :{' '} {att.check_out_at ? formatTime(att.check_out_at) : '-'} {late && ( Telat :{' '} {formatMinutes( lateMins, )}{' '} menit )} Jam Kerja :{' '} {formatMinutes( att.work_duration_minutes, )} ); })()} )} {!isAdmin && showAbsent && ( Tidak Hadir )} {!isAdmin && dayLeaves.map((leave) => ( Cuti ))}
); })}
{showCamera && ( setShowCamera(false)} /> )} !open && setDetailAttendance(null)} > {isAdmin && detailAttendance?.employee_name ? `${detailAttendance.employee_name} - ` : ''} {detailAttendance && format( new Date(detailAttendance.attendance_date), 'dd MMMM yyyy', { locale: id }, )} {detailAttendance && (
Foto Masuk {detailAttendance.check_in_photo ? ( Foto Masuk ) : (
Tidak ada foto
)}
{formatTime( detailAttendance.check_in_at, )}
Foto Pulang {detailAttendance.check_out_photo ? ( Foto Pulang ) : (
Tidak ada foto
)}
{detailAttendance.check_out_at ? ( <> {formatTime( detailAttendance.check_out_at, )} ) : ( <> Belum pulang )}
Lokasi Masuk
{detailAttendance.check_out_latitude && detailAttendance.check_out_longitude && (
Lokasi Pulang
)}
)}
); }