From bc02af2a66f7255e2a0401364a5e5448a66aebdf Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 6 Aug 2026 23:43:49 +0700 Subject: [PATCH] feat: enhance attendance management with employee-specific data and location tracking --- .../Admin/HR/AttendanceController.php | 25 +- app/Services/Admin/HR/AttendanceService.php | 32 +- resources/js/components/location-map.tsx | 54 +- .../js/pages/admin/hr/attendance/index.tsx | 604 ++++++++++-------- 4 files changed, 397 insertions(+), 318 deletions(-) diff --git a/app/Http/Controllers/Admin/HR/AttendanceController.php b/app/Http/Controllers/Admin/HR/AttendanceController.php index 438782c..9ea1aa7 100644 --- a/app/Http/Controllers/Admin/HR/AttendanceController.php +++ b/app/Http/Controllers/Admin/HR/AttendanceController.php @@ -22,19 +22,38 @@ public function index(Request $request): Response { $year = $request->integer('year', now()->year); $month = $request->integer('month', now()->month); - $hrSettings = app(HRSettings::class); + $user = auth()->user(); + $isAdmin = $user->hasAnyRole(['developer', 'owner', 'direktur']); + + if ($isAdmin) { + return Inertia::render('admin/hr/attendance/index', [ + 'attendances' => $this->service->getByMonth($year, $month), + 'todayAttendance' => null, + 'currentYear' => $year, + 'currentMonth' => $month, + 'monthStats' => $this->service->getMonthStats($year, $month), + 'hrSettings' => [ + 'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time, + 'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time, + ], + 'isAdmin' => true, + ]); + } + + $employeeId = $user->employee?->id; return Inertia::render('admin/hr/attendance/index', [ - 'attendances' => $this->service->getByMonth($year, $month), + 'attendances' => $this->service->getByMonth($year, $month, $employeeId), 'todayAttendance' => $this->service->getToday(), 'currentYear' => $year, 'currentMonth' => $month, - 'monthStats' => $this->service->getMonthStats($year, $month), + 'monthStats' => $this->service->getMonthStats($year, $month, $employeeId), 'hrSettings' => [ 'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time, 'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time, ], + 'isAdmin' => false, ]); } diff --git a/app/Services/Admin/HR/AttendanceService.php b/app/Services/Admin/HR/AttendanceService.php index 513f8c4..dda3933 100644 --- a/app/Services/Admin/HR/AttendanceService.php +++ b/app/Services/Admin/HR/AttendanceService.php @@ -3,6 +3,7 @@ namespace App\Services\Admin\HR; use App\Models\Attendance; +use App\Models\Employee; use App\Models\LeaveRequest; use App\Services\Concerns\RegistersMedia; use App\Services\NotificationService; @@ -27,11 +28,12 @@ public function getAll(): Collection ->map(fn (Attendance $attendance) => $this->formatAttendance($attendance)); } - public function getByMonth(int $year, int $month): Collection + public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection { return Attendance::with(['employee.user.userProfile', 'media']) ->whereYear('attendance_date', $year) ->whereMonth('attendance_date', $month) + ->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId)) ->get() ->map(fn (Attendance $attendance) => $this->formatAttendance($attendance)); } @@ -51,7 +53,14 @@ public function getToday(): ?array return $this->getByDate(now()->toDateString()); } - public function getMonthStats(int $year, int $month): array + public function getAllEmployees(): Collection + { + return Employee::with(['user.userProfile', 'user.roles']) + ->whereHas('user', fn ($q) => $q->where('is_active', true)) + ->get(); + } + + public function getMonthStats(int $year, int $month, ?int $employeeId = null): array { $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); $endOfMonth = $startOfMonth->copy()->endOfMonth(); @@ -65,14 +74,20 @@ public function getMonthStats(int $year, int $month): array $current->addDay(); } - $attendanceCount = Attendance::whereYear('attendance_date', $year) - ->whereMonth('attendance_date', $month) - ->count(); + $attendanceQuery = Attendance::whereYear('attendance_date', $year) + ->whereMonth('attendance_date', $month); + if ($employeeId) { + $attendanceQuery->where('employee_id', $employeeId); + } + $attendanceCount = $attendanceQuery->count(); - $leaveDays = LeaveRequest::approved() + $leaveQuery = LeaveRequest::approved() ->where('start_date', '<=', $endOfMonth) - ->where('end_date', '>=', $startOfMonth) - ->get() + ->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); @@ -154,6 +169,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance private function formatAttendance(Attendance $attendance): array { $toArray = $attendance->toArray(); + $toArray['employee_name'] = $attendance->employee?->user?->userProfile?->full_name ?? '-'; if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) { $checkIn = Carbon::parse($attendance->check_in_at); diff --git a/resources/js/components/location-map.tsx b/resources/js/components/location-map.tsx index 3896953..14f1635 100644 --- a/resources/js/components/location-map.tsx +++ b/resources/js/components/location-map.tsx @@ -1,7 +1,3 @@ -import L from 'leaflet'; -import { useEffect, useRef } from 'react'; -import 'leaflet/dist/leaflet.css'; - interface LocationMapProps { latitude: number; longitude: number; @@ -13,52 +9,18 @@ export function LocationMap({ latitude, longitude, height = '250px', - zoom = 15, + zoom = 17, }: LocationMapProps) { - const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - - useEffect(() => { - if (!mapRef.current || mapInstanceRef.current) { -return; -} - - const map = L.map(mapRef.current, { - center: [latitude, longitude], - zoom, - zoomControl: false, - attributionControl: true, - }); - - L.control.zoom({ position: 'topright' }).addTo(map); - - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: - '© OpenStreetMap', - }).addTo(map); - - const icon = L.divIcon({ - html: `
`, - className: '', - iconSize: [24, 24], - iconAnchor: [12, 12], - }); - - L.marker([latitude, longitude], { icon }).addTo(map); - - mapInstanceRef.current = map; - - return () => { - map.remove(); - mapInstanceRef.current = null; - }; - }, [latitude, longitude, zoom]); + const src = `https://maps.google.com/maps?q=${latitude},${longitude}&z=${zoom}&t=k&output=embed`; return ( -
); } diff --git a/resources/js/pages/admin/hr/attendance/index.tsx b/resources/js/pages/admin/hr/attendance/index.tsx index b87f4b6..fb48de8 100644 --- a/resources/js/pages/admin/hr/attendance/index.tsx +++ b/resources/js/pages/admin/hr/attendance/index.tsx @@ -18,6 +18,7 @@ import { toast } from 'sonner'; import { CameraCapture } from '@/components/camera-capture'; import { LocationMap } from '@/components/location-map'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { @@ -44,6 +45,7 @@ type Attendance = { check_out_latitude: number | null; check_out_longitude: number | null; work_duration_minutes: number | null; + employee_name: string; }; type MonthStats = { @@ -58,11 +60,12 @@ type Props = { todayAttendance: Attendance | null; currentYear: number; currentMonth: number; - monthStats: MonthStats; + monthStats: MonthStats | null; hrSettings: { scheduled_check_in_time: string; scheduled_check_out_time: string; }; + isAdmin: boolean; }; const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab']; @@ -83,16 +86,6 @@ function isSameDay(d1: Date, d2: Date): boolean { ); } -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(); @@ -105,8 +98,8 @@ function isLate( officeMinute: number, ): boolean { if (!checkInAt) { -return false; -} + return false; + } const d = new Date(checkInAt); const h = d.getHours(); @@ -121,8 +114,8 @@ function getLateMinutes( officeMinute: number, ): number { if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) { -return 0; -} + return 0; + } const d = new Date(checkInAt); const officeStart = new Date(d); @@ -133,15 +126,15 @@ return 0; function formatMinutes(minutes: number | null): string { if (!minutes) { -return '-'; -} + return '-'; + } const hours = Math.floor(minutes / 60); const mins = Math.floor(minutes % 60); if (mins === 0) { -return `${hours} jam`; -} + return `${hours} jam`; + } return `${hours} jam ${mins} menit`; } @@ -153,6 +146,7 @@ export default function AttendanceIndex({ currentMonth, monthStats, hrSettings, + isAdmin, }: Props) { const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time .split(':') @@ -173,21 +167,17 @@ export default function AttendanceIndex({ const viewYear = viewDate.getFullYear(); const viewMonth = viewDate.getMonth() + 1; - const attendanceDates = useMemo(() => { - const dates = new Map(); + const attendanceByDate = useMemo(() => { + const map = new Map(); attendances.forEach((att) => { - dates.set(att.attendance_date, att); + const existing = map.get(att.attendance_date) ?? []; + existing.push(att); + map.set(att.attendance_date, existing); }); - return dates; + return map; }, [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); @@ -232,8 +222,51 @@ export default function AttendanceIndex({ return days; }, [viewYear, viewMonth]); - const handlePrevMonth = () => setViewDate((d) => subMonths(d, 1)); - const handleNextMonth = () => setViewDate((d) => addMonths(d, 1)); + const handlePrevMonth = () => { + const newDate = subMonths(viewDate, 1); + setViewDate(newDate); + + if (!isAdmin) { + router.get( + attendanceIndex.url(), + { + year: newDate.getFullYear(), + month: newDate.getMonth() + 1, + }, + { preserveState: true, preserveScroll: true }, + ); + } + }; + + const handleNextMonth = () => { + const newDate = addMonths(viewDate, 1); + setViewDate(newDate); + + if (!isAdmin) { + router.get( + attendanceIndex.url(), + { + year: newDate.getFullYear(), + month: newDate.getMonth() + 1, + }, + { preserveState: true, preserveScroll: true }, + ); + } + }; + + const handleGoToToday = () => { + const now = new Date(); + setViewDate(now); + setSelectedDate(now); + + if (!isAdmin) { + router.get( + attendanceIndex.url(), + { year: now.getFullYear(), month: now.getMonth() + 1 }, + { preserveState: true, preserveScroll: true }, + ); + } + }; const handleCameraCapture = (dataUrl: string) => { setShowCamera(false); @@ -290,8 +323,8 @@ export default function AttendanceIndex({ function formatTime(dateStr: string | null): string { if (!dateStr) { -return '-'; -} + return '-'; + } return format(new Date(dateStr), 'HH:mm'); } @@ -305,167 +338,168 @@ return '-'; <> -
+

Presensi

- {/* Alert Status Presensi */} - - - Presensi Hari Ini - -
-
- {hasCheckedIn ? ( -
-
- - - Masuk: - - - {formatTime( - todayAttendance?.check_in_at, + {!isAdmin && ( + + + Presensi Hari Ini + +
+
+ {hasCheckedIn ? ( +
+
+ + + Masuk: + + + {formatTime( + todayAttendance?.check_in_at, + )} + +
+
+ {hasCheckedOut ? ( + <> + + + Pulang: + + + {formatTime( + todayAttendance?.check_out_at, + )} + + + ) : ( + <> + + + Pulang: + + + Belum + + )} - +
-
- {hasCheckedOut ? ( - <> - - - Pulang: - - - {formatTime( - todayAttendance?.check_out_at, - )} - - - ) : ( - <> - - - Pulang: - - - Belum - - - )} -
-
- ) : ( -

- Anda belum melakukan presensi hari ini + ) : ( +

+ Anda belum melakukan presensi hari ini +

+ )} +
+ +
+ {locationLoading && ( + + Mendapatkan lokasi... + + )} + + +
+
+ + + )} + + {monthStats && ( +
+ + +
+ +
+
+

+ Hari Kerja

- )} -
- -
- {locationLoading && ( - - Mendapatkan lokasi... - - )} - - -
-
- - - - {/* Summary Stats */} -
- - -
- -
-
-

- Hari Kerja -

-

- {monthStats.working_days} -

-
-
-
- - -
- -
-
-

- Hadir -

-

- {monthStats.present} -

-
-
-
- - -
- -
-
-

- Tidak Hadir -

-

- {monthStats.absent} -

-
-
-
- - -
- -
-
-

- Cuti -

-

- {monthStats.leave} -

-
-
-
-
+

+ {monthStats.working_days} +

+
+ + + + +
+ +
+
+

+ Hadir +

+

+ {monthStats.present} +

+
+
+
+ + +
+ +
+
+

+ Tidak Hadir +

+

+ {monthStats.absent} +

+
+
+
+ + +
+ +
+
+

+ Cuti +

+

+ {monthStats.leave} +

+
+
+
+
+ )} - {/* Calendar Header */}
@@ -503,10 +537,7 @@ return '-'; variant="ghost" size="sm" className="h-8 px-3 text-xs font-medium" - onClick={() => { - setViewDate(new Date()); - setSelectedDate(new Date()); - }} + onClick={handleGoToToday} > Hari ini @@ -521,7 +552,6 @@ return '-';
- {/* Weekday Headers */}
{WEEKDAYS.map((day) => (
- {/* Calendar Grid */}
{calendarDays.map((cell, idx) => { const dateStr = format(cell.date, 'yyyy-MM-dd'); - const attendance = attendanceDates.get(dateStr); + const dayAttendances = attendanceByDate.get(dateStr) ?? []; const isSelected = isSameDay( cell.date, selectedDate, @@ -547,47 +576,33 @@ return '-'; new Date(), ); - const today = new Date(); - today.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 < today; + const isPastDate = cellDate < todayMidnight; 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; + dayAttendances.length === 0; return ( -
-
- {attendance && ( +
+ {isAdmin ? ( <> - - {late - ? 'Terlambat' - : 'Hadir'} - - - Masuk :{' '} - {formatTime( - attendance.check_in_at, - )} - - - Pulang :{' '} - {formatTime( - attendance.check_out_at, - )} - - {late && ( - - Telat :{' '} - {formatMinutes( - lateMins, - )}{' '} - menit - - )} - - Jam Kerja :{' '} - {formatMinutes( - attendance.work_duration_minutes, - )} - + {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 && ( @@ -652,7 +713,7 @@ setDetailAttendance(attendance); )}
- +
); })}
@@ -673,6 +734,9 @@ setDetailAttendance(attendance); + {isAdmin && detailAttendance?.employee_name + ? `${detailAttendance.employee_name} - ` + : ''} Detail Presensi -{' '} {detailAttendance && format( @@ -749,19 +813,37 @@ setDetailAttendance(attendance);
-
- - Lokasi Presensi - - +
+
+ + Lokasi Masuk + + +
+ {detailAttendance.check_out_latitude && detailAttendance.check_out_longitude && ( +
+ + Lokasi Pulang + + +
+ )}
)}