dstpabuaran.com/resources/js/pages/admin/hr/attendance/index.tsx
Yoga Pangestu 67e5a6271f Add tests for CheckAttendancePenaltiesJob to validate attendance penalties logic
- Implement tests to ensure job skips execution on weekends, when penalties are zero, or when no payroll period exists.
- Validate late penalty creation for late check-ins and ensure no penalties for on-time or early check-ins.
- Test absent penalties for employees without attendance records.
- Ensure no duplicate penalties are created and that payroll recalculations are accurate after penalties are applied.
- Handle multiple employees and edge cases, including employees without associated users.
- Verify that the job can be dispatched to the queue and has the correct retry configuration.
2026-07-31 11:13:46 +07:00

527 lines
26 KiB
TypeScript

import { CameraCapture } from '@/components/camera-capture';
import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { index as attendanceIndex, store, update } from '@/routes/admin/hr/attendances';
import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale';
import { CalendarCheck, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock, LogIn, LogOut, UserX, Wallet } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
type Attendance = {
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;
};
type MonthStats = {
working_days: number;
present: number;
absent: number;
leave: number;
};
type Props = {
attendances: Attendance[];
todayAttendance: Attendance | null;
currentYear: number;
currentMonth: number;
monthStats: MonthStats;
hrSettings: {
scheduled_check_in_time: string;
scheduled_check_out_time: string;
};
};
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 checkIsToday(date: Date): boolean {
const t = new Date();
return date.getDate() === t.getDate() && date.getMonth() === t.getMonth() && date.getFullYear() === t.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, todayAttendance, currentYear, currentMonth, monthStats, hrSettings }: 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<Date>(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<Attendance | null>(null);
const viewYear = viewDate.getFullYear();
const viewMonth = viewDate.getMonth() + 1;
const attendanceDates = useMemo(() => {
const dates = new Map<string, Attendance>();
attendances.forEach((att) => {
dates.set(att.attendance_date, att);
});
return dates;
}, [attendances]);
const selectedAttendance = useMemo(() => {
const dateStr = format(selectedDate, 'yyyy-MM-dd');
return attendanceDates.get(dateStr) ?? null;
}, [selectedDate, attendanceDates]);
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 = () => setViewDate((d) => subMonths(d, 1));
const handleNextMonth = () => setViewDate((d) => addMonths(d, 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 hasCheckedIn = !!todayAttendance;
const hasCheckedOut = !!todayAttendance?.check_out_at;
return (
<>
<Head title="Presensi" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Presensi</h2>
</div>
{/* Alert Status Presensi */}
<Alert>
<CalendarCheck className="h-4 w-4" />
<AlertTitle>Presensi Hari Ini</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
{hasCheckedIn ? (
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">Masuk:</span>
<span className="font-medium">{formatTime(todayAttendance?.check_in_at)}</span>
</div>
<div className="flex items-center gap-1.5">
{hasCheckedOut ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">Pulang:</span>
<span className="font-medium">{formatTime(todayAttendance?.check_out_at)}</span>
</>
) : (
<>
<Clock className="h-3.5 w-3.5 text-orange-500" />
<span className="text-muted-foreground">Pulang:</span>
<span className="font-medium text-orange-600">Belum</span>
</>
)}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Anda belum melakukan presensi hari ini</p>
)}
</div>
<div className="flex items-center gap-2">
{locationLoading && (
<span className="text-xs text-muted-foreground">Mendapatkan lokasi...</span>
)}
<Button
size="sm"
onClick={handleCheckIn}
disabled={hasCheckedIn || locationLoading}
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
Presensi Masuk
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCheckOut}
disabled={!hasCheckedIn || hasCheckedOut || locationLoading}
>
<LogOut className="mr-1.5 h-3.5 w-3.5" />
Presensi Pulang
</Button>
</div>
</div>
</AlertDescription>
</Alert>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100">
<CalendarDays className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Hari Kerja</p>
<p className="text-lg font-bold">{monthStats.working_days}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-green-100">
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Hadir</p>
<p className="text-lg font-bold">{monthStats.present}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-100">
<UserX className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Tidak Hadir</p>
<p className="text-lg font-bold">{monthStats.absent}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-100">
<Wallet className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Cuti</p>
<p className="text-lg font-bold">{monthStats.leave}</p>
</div>
</CardContent>
</Card>
</div>
<Card className="overflow-hidden" style={{ '--card-spacing': '0px' } as React.CSSProperties}>
{/* Calendar Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
<div className="bg-muted px-3 py-0.5">
<span className="text-xs font-semibold uppercase text-muted-foreground">
{format(viewDate, 'MMM', { locale: id })}
</span>
</div>
<div className="px-3 py-1">
<span className="text-lg font-bold text-primary">{format(viewDate, 'dd')}</span>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
{format(viewDate, 'MMMM yyyy', { locale: id })}
</h3>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 px-3 text-xs font-medium" onClick={() => { setViewDate(new Date()); setSelectedDate(new Date()); }}>
Hari ini
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 border-b">
{WEEKDAYS.map((day) => (
<div key={day} className="flex items-center justify-center py-3 text-xs font-medium text-muted-foreground">
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7">
{calendarDays.map((cell, idx) => {
const dateStr = format(cell.date, 'yyyy-MM-dd');
const attendance = attendanceDates.get(dateStr);
const isSelected = isSameDay(cell.date, selectedDate);
const isTodayDate = isSameDay(cell.date, new Date());
const today = new Date();
today.setHours(0, 0, 0, 0);
const cellDate = new Date(cell.date);
cellDate.setHours(0, 0, 0, 0);
const isPastDate = cellDate < today;
const showAbsent = cell.isCurrentMonth && isPastDate && !isWeekend(cell.date) && !attendance;
const late = attendance ? isLate(attendance.check_in_at, officeHour, officeMinute) : false;
const lateMins = attendance ? getLateMinutes(attendance.check_in_at, officeHour, officeMinute) : 0;
return (
<button
key={idx}
onClick={() => {
setSelectedDate(cell.date);
if (attendance) setDetailAttendance(attendance);
}}
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' : ''
}`}
style={{
borderRight: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
}}
>
<div className="flex items-start justify-between">
<span
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${
isSelected
? 'bg-primary text-primary-foreground'
: isTodayDate
? 'bg-muted text-foreground'
: 'text-foreground'
}`}
>
{cell.day}
</span>
</div>
<div className="mt-1 flex flex-col gap-0.5">
{attendance && (
<>
<span className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>
{late ? 'Terlambat' : 'Hadir'}
</span>
<span className="text-[10px] text-muted-foreground">Masuk : {formatTime(attendance.check_in_at)}</span>
<span className="text-[10px] text-muted-foreground">Pulang : {formatTime(attendance.check_out_at)}</span>
{late && (
<span className="text-[10px] text-yellow-600">Telat : {formatMinutes(lateMins)} menit</span>
)}
<span className="text-[10px] text-muted-foreground">Jam Kerja : {formatMinutes(attendance.work_duration_minutes)}</span>
</>
)}
{showAbsent && (
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
Tidak Hadir
</span>
)}
</div>
</button>
);
})}
</div>
</Card>
</div>
{showCamera && (
<CameraCapture
onCapture={handleCameraCapture}
onClose={() => setShowCamera(false)}
/>
)}
<Dialog open={!!detailAttendance} onOpenChange={(open) => !open && setDetailAttendance(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
Detail Presensi - {detailAttendance && format(new Date(detailAttendance.attendance_date), 'dd MMMM yyyy', { locale: id })}
</DialogTitle>
</DialogHeader>
{detailAttendance && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Foto Masuk</span>
{detailAttendance.check_in_photo ? (
<img
src={detailAttendance.check_in_photo}
alt="Foto Masuk"
className="w-full rounded-lg border object-cover"
/>
) : (
<div className="flex h-40 items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground">
Tidak ada foto
</div>
)}
<div className="flex items-center gap-2 text-sm">
<LogIn className="h-4 w-4 text-green-600" />
<span className="font-medium">{formatTime(detailAttendance.check_in_at)}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Foto Pulang</span>
{detailAttendance.check_out_photo ? (
<img
src={detailAttendance.check_out_photo}
alt="Foto Pulang"
className="w-full rounded-lg border object-cover"
/>
) : (
<div className="flex h-40 items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground">
Tidak ada foto
</div>
)}
<div className="flex items-center gap-2 text-sm">
{detailAttendance.check_out_at ? (
<>
<LogOut className="h-4 w-4 text-blue-600" />
<span className="font-medium">{formatTime(detailAttendance.check_out_at)}</span>
</>
) : (
<>
<Clock className="h-4 w-4 text-orange-500" />
<span className="font-medium text-orange-600">Belum pulang</span>
</>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Lokasi Presensi</span>
<LocationMap
latitude={detailAttendance.check_in_latitude}
longitude={detailAttendance.check_in_longitude}
height="200px"
/>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
AttendanceIndex.layout = {
breadcrumbs: [
{
title: 'HR',
href: attendanceIndex(),
},
{
title: 'Presensi',
href: attendanceIndex(),
},
],
};