diff --git a/app/Http/Controllers/Admin/HR/AttendanceController.php b/app/Http/Controllers/Admin/HR/AttendanceController.php index 9ea1aa7..6edc801 100644 --- a/app/Http/Controllers/Admin/HR/AttendanceController.php +++ b/app/Http/Controllers/Admin/HR/AttendanceController.php @@ -24,11 +24,13 @@ public function index(Request $request): Response $month = $request->integer('month', now()->month); $hrSettings = app(HRSettings::class); $user = auth()->user(); - $isAdmin = $user->hasAnyRole(['developer', 'owner', 'direktur']); + $isAdmin = $user->hasAnyRole(['developer', 'owner']); if ($isAdmin) { return Inertia::render('admin/hr/attendance/index', [ 'attendances' => $this->service->getByMonth($year, $month), + 'leaves' => $this->service->getLeavesByMonth($year, $month), + 'employees' => $this->service->getAllEmployees(), 'todayAttendance' => null, 'currentYear' => $year, 'currentMonth' => $month, @@ -42,9 +44,11 @@ public function index(Request $request): Response } $employeeId = $user->employee?->id; + $isOnLeave = $this->service->isOnLeave($user); return Inertia::render('admin/hr/attendance/index', [ 'attendances' => $this->service->getByMonth($year, $month, $employeeId), + 'leaves' => $this->service->getLeavesByMonth($year, $month, $employeeId), 'todayAttendance' => $this->service->getToday(), 'currentYear' => $year, 'currentMonth' => $month, @@ -53,6 +57,8 @@ public function index(Request $request): Response 'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time, 'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time, ], + 'isOnLeave' => $isOnLeave, + 'canCheckIn' => $user->employee !== null, 'isAdmin' => false, ]); } @@ -60,7 +66,7 @@ public function index(Request $request): Response public function store(AttendanceRequest $request): RedirectResponse { return $this->handleAction( - fn () => $this->service->checkIn($request->validated()), + fn() => $this->service->checkIn($request->validated()), 'Berhasil check-in.', 'admin.hr.attendances.index' ); @@ -69,7 +75,7 @@ public function store(AttendanceRequest $request): RedirectResponse public function update(AttendanceRequest $request, Attendance $attendance): RedirectResponse { return $this->handleAction( - fn () => $this->service->checkOut($attendance, $request->validated()), + fn() => $this->service->checkOut($attendance, $request->validated()), 'Berhasil check-out.', 'admin.hr.attendances.index' ); diff --git a/app/Services/Admin/HR/AttendanceService.php b/app/Services/Admin/HR/AttendanceService.php index dda3933..9f12e7e 100644 --- a/app/Services/Admin/HR/AttendanceService.php +++ b/app/Services/Admin/HR/AttendanceService.php @@ -5,6 +5,7 @@ use App\Models\Attendance; use App\Models\Employee; use App\Models\LeaveRequest; +use App\Models\User; use App\Services\Concerns\RegistersMedia; use App\Services\NotificationService; use App\Services\S3PresignedService; @@ -53,54 +54,161 @@ public function getToday(): ?array return $this->getByDate(now()->toDateString()); } + public function isOnLeave(User $user): bool + { + $employee = $user->employee; + + if (! $employee) { + return false; + } + + return LeaveRequest::approved() + ->where('employee_id', $employee->id) + ->where('start_date', '<=', now()->toDateString()) + ->where('end_date', '>=', now()->toDateString()) + ->exists(); + } + + public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null): Collection + { + $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); + $endOfMonth = $startOfMonth->copy()->endOfMonth(); + + return LeaveRequest::approved() + ->with('employee.user.userProfile') + ->where('start_date', '<=', $endOfMonth) + ->where('end_date', '>=', $startOfMonth) + ->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId)) + ->get() + ->map(fn (LeaveRequest $leave) => [ + 'id' => $leave->id, + 'employee_id' => $leave->employee_id, + 'start_date' => $leave->start_date->toDateString(), + 'end_date' => $leave->end_date->toDateString(), + 'total_days' => $leave->total_days, + 'status' => $leave->status->value, + 'employee_name' => $leave->employee?->user?->userProfile?->full_name ?? '-', + ]); + } + public function getAllEmployees(): Collection { return Employee::with(['user.userProfile', 'user.roles']) ->whereHas('user', fn ($q) => $q->where('is_active', true)) - ->get(); + ->get() + ->map(fn (Employee $employee) => [ + 'id' => $employee->id, + 'name' => $employee->user?->userProfile?->full_name ?? '-', + ]); } public function getMonthStats(int $year, int $month, ?int $employeeId = null): array { $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); $endOfMonth = $startOfMonth->copy()->endOfMonth(); + $today = Carbon::today(); + $statEnd = $endOfMonth->lte($today) ? $endOfMonth : $today; $workingDays = 0; $current = $startOfMonth->copy(); - while ($current->lte($endOfMonth)) { + while ($current->lte($statEnd)) { if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) { $workingDays++; } $current->addDay(); } - $attendanceQuery = Attendance::whereYear('attendance_date', $year) - ->whereMonth('attendance_date', $month); if ($employeeId) { - $attendanceQuery->where('employee_id', $employeeId); + return $this->getMonthStatsForEmployee($year, $month, $startOfMonth, $statEnd, $workingDays, $employeeId); } - $attendanceCount = $attendanceQuery->count(); + + return $this->getMonthStatsForAll($year, $month, $startOfMonth, $statEnd, $workingDays); + } + + private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays, int $employeeId): array + { + $attendanceQuery = Attendance::whereYear('attendance_date', $year) + ->whereMonth('attendance_date', $month) + ->where('attendance_date', '<=', $statEnd->toDateString()) + ->where('employee_id', $employeeId); + + $attendanceDates = $attendanceQuery->pluck('attendance_date') + ->map(fn ($d) => Carbon::parse($d)->toDateString()) + ->filter(fn ($d) => ! in_array(Carbon::parse($d)->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) + ->unique() + ->values(); + $attendanceCount = $attendanceDates->count(); $leaveQuery = LeaveRequest::approved() - ->where('start_date', '<=', $endOfMonth) - ->where('end_date', '>=', $startOfMonth); - if ($employeeId) { - $leaveQuery->where('employee_id', $employeeId); - } - $leaveDays = $leaveQuery->get() - ->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) { - $leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp); - $leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp); - $days = Carbon::createFromTimestamp($leaveStart)->diffInDays(Carbon::createFromTimestamp($leaveEnd)) + 1; + ->where('start_date', '<=', $statEnd) + ->where('end_date', '>=', $startOfMonth) + ->where('employee_id', $employeeId); - return $carry + max(0, $days); - }, 0); + $leaveRequests = $leaveQuery->get(); + $leaveCount = $leaveRequests->count(); + + $leaveDates = collect(); + $leaveRequests->each(function ($leave) use (&$leaveDates, $startOfMonth, $statEnd) { + $leaveStart = Carbon::parse($leave->start_date)->startOfDay()->lte($startOfMonth) ? $startOfMonth->copy() : Carbon::parse($leave->start_date)->startOfDay(); + $leaveEnd = Carbon::parse($leave->end_date)->startOfDay()->gte($statEnd) ? $statEnd->copy() : Carbon::parse($leave->end_date)->startOfDay(); + $current = $leaveStart->copy(); + while ($current->lte($leaveEnd)) { + if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) { + $leaveDates->push($current->toDateString()); + } + $current->addDay(); + } + }); + $leaveDates = $leaveDates->unique()->values(); + + $coveredDates = $attendanceDates->merge($leaveDates)->unique()->count(); + $absent = max(0, $workingDays - $coveredDates); return [ 'working_days' => $workingDays, 'present' => $attendanceCount, - 'absent' => max(0, $workingDays - $attendanceCount - $leaveDays), + 'absent' => $absent, + 'leave' => $leaveCount, + ]; + } + + private function getMonthStatsForAll(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays): array + { + $totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count(); + + $presentCount = Attendance::whereYear('attendance_date', $year) + ->whereMonth('attendance_date', $month) + ->where('attendance_date', '<=', $statEnd->toDateString()) + ->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true))) + ->count(); + + $leaveRequests = LeaveRequest::approved() + ->where('start_date', '<=', $statEnd) + ->where('end_date', '>=', $startOfMonth) + ->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true))) + ->get(); + + $leaveDays = 0; + $leaveRequests->each(function ($leave) use (&$leaveDays, $startOfMonth, $statEnd) { + $leaveStart = Carbon::parse($leave->start_date)->startOfDay()->lte($startOfMonth) ? $startOfMonth->copy() : Carbon::parse($leave->start_date)->startOfDay(); + $leaveEnd = Carbon::parse($leave->end_date)->startOfDay()->gte($statEnd) ? $statEnd->copy() : Carbon::parse($leave->end_date)->startOfDay(); + $current = $leaveStart->copy(); + while ($current->lte($leaveEnd)) { + if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) { + $leaveDays++; + } + $current->addDay(); + } + }); + + $absent = max(0, ($totalEmployees * $workingDays) - $presentCount - $leaveDays); + + return [ + 'working_days' => $workingDays, + 'present' => $presentCount, + 'absent' => $absent, 'leave' => $leaveDays, + 'total_employees' => $totalEmployees, ]; } diff --git a/resources/js/pages/admin/hr/attendance/index.tsx b/resources/js/pages/admin/hr/attendance/index.tsx index a075a83..b3a9d72 100644 --- a/resources/js/pages/admin/hr/attendance/index.tsx +++ b/resources/js/pages/admin/hr/attendance/index.tsx @@ -2,7 +2,7 @@ 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, CardContent } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Dialog, DialogContent, @@ -18,21 +18,18 @@ import { Head, router } from '@inertiajs/react'; import { addMonths, format, subMonths } from 'date-fns'; import { id } from 'date-fns/locale'; import { - 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; + employee_id: number; attendance_date: string; check_in_at: string | null; check_out_at: string | null; @@ -46,23 +43,34 @@ type Attendance = { employee_name: string; }; -type MonthStats = { - working_days: number; - present: number; - absent: number; - leave: number; +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; - monthStats: MonthStats | null; hrSettings: { scheduled_check_in_time: string; scheduled_check_out_time: string; }; + isOnLeave: boolean; + canCheckIn: boolean; isAdmin: boolean; }; @@ -139,11 +147,14 @@ function formatMinutes(minutes: number | null): string { export default function AttendanceIndex({ attendances, + leaves, + employees = [], todayAttendance, currentYear, currentMonth, - monthStats, hrSettings, + isOnLeave, + canCheckIn, isAdmin, }: Props) { const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time @@ -176,6 +187,24 @@ export default function AttendanceIndex({ 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); @@ -343,76 +372,14 @@ export default function AttendanceIndex({ {!isAdmin && ( )} - {monthStats && ( -
- - -
- -
-
-

- Hari Kerja -

-

- {monthStats.working_days} -

-
-
-
- - -
- -
-
-

- Hadir -

-

- {monthStats.present} -

-
-
-
- - -
- -
-
-

- Tidak Hadir -

-

- {monthStats.absent} -

-
-
-
- - -
- -
-
-

- Cuti -

-

- {monthStats.leave} -

-
-
-
-
- )}
@@ -467,6 +434,14 @@ export default function AttendanceIndex({
+ {isAdmin && ( +
+ Hadir + Terlambat + Cuti +
+ )} +
{WEEKDAYS.map((day) => ( @@ -481,154 +456,190 @@ export default function AttendanceIndex({
{calendarDays.map((cell, idx) => { - const dateStr = format(cell.date, 'yyyy-MM-dd'); - const dayAttendances = attendanceByDate.get(dateStr) ?? []; - const isSelected = isSameDay( - cell.date, - selectedDate, - ); - const isTodayDate = isSameDay( - cell.date, - new Date(), - ); + 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 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 showAbsent = - cell.isCurrentMonth && - isPastDate && - !isWeekend(cell.date) && - dayAttendances.length === 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); + 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 ? ( - <> - {dayAttendances.map((att) => { - const late = isLate( - att.check_in_at, - officeHour, - officeMinute, - ); - - return ( - - ); - })} - - ) : ( - <> - {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'} - - - Masuk :{' '} - {formatTime( - att.check_in_at, - )} - - - Pulang :{' '} - {formatTime( - att.check_out_at, - )} - - {late && ( - - Telat :{' '} - {formatMinutes( - lateMins, - )}{' '} - menit - - )} - - Jam Kerja :{' '} - {formatMinutes( - att.work_duration_minutes, - )} - - - ); - })()} - - )} - {showAbsent && ( - - Tidak Hadir + 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 ? ( + <> + {[...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, + ); + 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'} + + + Masuk :{' '} + {formatTime( + att.check_in_at, + )} + + + Pulang :{' '} + {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 + + ))} +
-
- ); + ); })}
@@ -652,7 +663,6 @@ export default function AttendanceIndex({ {isAdmin && detailAttendance?.employee_name ? `${detailAttendance.employee_name} - ` : ''} - Detail Presensi -{' '} {detailAttendance && format( new Date(detailAttendance.attendance_date),