feat: enhance attendance management with employee-specific data and location tracking

This commit is contained in:
Yoga Pangestu 2026-08-06 23:43:49 +07:00
parent 9bbb00c69f
commit bc02af2a66
4 changed files with 397 additions and 318 deletions

View File

@ -22,19 +22,38 @@ public function index(Request $request): Response
{ {
$year = $request->integer('year', now()->year); $year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month); $month = $request->integer('month', now()->month);
$hrSettings = app(HRSettings::class); $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', [ return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month), 'attendances' => $this->service->getByMonth($year, $month, $employeeId),
'todayAttendance' => $this->service->getToday(), 'todayAttendance' => $this->service->getToday(),
'currentYear' => $year, 'currentYear' => $year,
'currentMonth' => $month, 'currentMonth' => $month,
'monthStats' => $this->service->getMonthStats($year, $month), 'monthStats' => $this->service->getMonthStats($year, $month, $employeeId),
'hrSettings' => [ 'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time, 'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time, 'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
], ],
'isAdmin' => false,
]); ]);
} }

View File

@ -3,6 +3,7 @@
namespace App\Services\Admin\HR; namespace App\Services\Admin\HR;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService; use App\Services\NotificationService;
@ -27,11 +28,12 @@ public function getAll(): Collection
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance)); ->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']) return Attendance::with(['employee.user.userProfile', 'media'])
->whereYear('attendance_date', $year) ->whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month) ->whereMonth('attendance_date', $month)
->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId))
->get() ->get()
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance)); ->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
} }
@ -51,7 +53,14 @@ public function getToday(): ?array
return $this->getByDate(now()->toDateString()); 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(); $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
$endOfMonth = $startOfMonth->copy()->endOfMonth(); $endOfMonth = $startOfMonth->copy()->endOfMonth();
@ -65,14 +74,20 @@ public function getMonthStats(int $year, int $month): array
$current->addDay(); $current->addDay();
} }
$attendanceCount = Attendance::whereYear('attendance_date', $year) $attendanceQuery = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month) ->whereMonth('attendance_date', $month);
->count(); if ($employeeId) {
$attendanceQuery->where('employee_id', $employeeId);
}
$attendanceCount = $attendanceQuery->count();
$leaveDays = LeaveRequest::approved() $leaveQuery = LeaveRequest::approved()
->where('start_date', '<=', $endOfMonth) ->where('start_date', '<=', $endOfMonth)
->where('end_date', '>=', $startOfMonth) ->where('end_date', '>=', $startOfMonth);
->get() if ($employeeId) {
$leaveQuery->where('employee_id', $employeeId);
}
$leaveDays = $leaveQuery->get()
->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) { ->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) {
$leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp); $leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp);
$leaveEnd = min($leave->end_date->timestamp, $endOfMonth->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 private function formatAttendance(Attendance $attendance): array
{ {
$toArray = $attendance->toArray(); $toArray = $attendance->toArray();
$toArray['employee_name'] = $attendance->employee?->user?->userProfile?->full_name ?? '-';
if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) { if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) {
$checkIn = Carbon::parse($attendance->check_in_at); $checkIn = Carbon::parse($attendance->check_in_at);

View File

@ -1,7 +1,3 @@
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import 'leaflet/dist/leaflet.css';
interface LocationMapProps { interface LocationMapProps {
latitude: number; latitude: number;
longitude: number; longitude: number;
@ -13,52 +9,18 @@ export function LocationMap({
latitude, latitude,
longitude, longitude,
height = '250px', height = '250px',
zoom = 15, zoom = 17,
}: LocationMapProps) { }: LocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null); const src = `https://maps.google.com/maps?q=${latitude},${longitude}&z=${zoom}&t=k&output=embed`;
const mapInstanceRef = useRef<L.Map | null>(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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
const icon = L.divIcon({
html: `<div style="background: #ef4444; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>`,
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]);
return ( return (
<div <iframe
ref={mapRef} src={src}
style={{ height, width: '100%' }} style={{ height, width: '100%', border: 0 }}
className="rounded-lg" className="rounded-lg"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Lokasi Presensi"
/> />
); );
} }

View File

@ -18,6 +18,7 @@ import { toast } from 'sonner';
import { CameraCapture } from '@/components/camera-capture'; import { CameraCapture } from '@/components/camera-capture';
import { LocationMap } from '@/components/location-map'; import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
@ -44,6 +45,7 @@ type Attendance = {
check_out_latitude: number | null; check_out_latitude: number | null;
check_out_longitude: number | null; check_out_longitude: number | null;
work_duration_minutes: number | null; work_duration_minutes: number | null;
employee_name: string;
}; };
type MonthStats = { type MonthStats = {
@ -58,11 +60,12 @@ type Props = {
todayAttendance: Attendance | null; todayAttendance: Attendance | null;
currentYear: number; currentYear: number;
currentMonth: number; currentMonth: number;
monthStats: MonthStats; monthStats: MonthStats | null;
hrSettings: { hrSettings: {
scheduled_check_in_time: string; scheduled_check_in_time: string;
scheduled_check_out_time: string; scheduled_check_out_time: string;
}; };
isAdmin: boolean;
}; };
const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab']; 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 { function isWeekend(date: Date): boolean {
const day = date.getDay(); const day = date.getDay();
@ -105,8 +98,8 @@ function isLate(
officeMinute: number, officeMinute: number,
): boolean { ): boolean {
if (!checkInAt) { if (!checkInAt) {
return false; return false;
} }
const d = new Date(checkInAt); const d = new Date(checkInAt);
const h = d.getHours(); const h = d.getHours();
@ -121,8 +114,8 @@ function getLateMinutes(
officeMinute: number, officeMinute: number,
): number { ): number {
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) { if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
return 0; return 0;
} }
const d = new Date(checkInAt); const d = new Date(checkInAt);
const officeStart = new Date(d); const officeStart = new Date(d);
@ -133,15 +126,15 @@ return 0;
function formatMinutes(minutes: number | null): string { function formatMinutes(minutes: number | null): string {
if (!minutes) { if (!minutes) {
return '-'; return '-';
} }
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60); const mins = Math.floor(minutes % 60);
if (mins === 0) { if (mins === 0) {
return `${hours} jam`; return `${hours} jam`;
} }
return `${hours} jam ${mins} menit`; return `${hours} jam ${mins} menit`;
} }
@ -153,6 +146,7 @@ export default function AttendanceIndex({
currentMonth, currentMonth,
monthStats, monthStats,
hrSettings, hrSettings,
isAdmin,
}: Props) { }: Props) {
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
.split(':') .split(':')
@ -173,21 +167,17 @@ export default function AttendanceIndex({
const viewYear = viewDate.getFullYear(); const viewYear = viewDate.getFullYear();
const viewMonth = viewDate.getMonth() + 1; const viewMonth = viewDate.getMonth() + 1;
const attendanceDates = useMemo(() => { const attendanceByDate = useMemo(() => {
const dates = new Map<string, Attendance>(); const map = new Map<string, Attendance[]>();
attendances.forEach((att) => { 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]); }, [attendances]);
const selectedAttendance = useMemo(() => {
const dateStr = format(selectedDate, 'yyyy-MM-dd');
return attendanceDates.get(dateStr) ?? null;
}, [selectedDate, attendanceDates]);
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const daysInMonth = getDaysInMonth(viewYear, viewMonth); const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfMonth(viewYear, viewMonth); const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
@ -232,8 +222,51 @@ export default function AttendanceIndex({
return days; return days;
}, [viewYear, viewMonth]); }, [viewYear, viewMonth]);
const handlePrevMonth = () => setViewDate((d) => subMonths(d, 1)); const handlePrevMonth = () => {
const handleNextMonth = () => setViewDate((d) => addMonths(d, 1)); 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) => { const handleCameraCapture = (dataUrl: string) => {
setShowCamera(false); setShowCamera(false);
@ -290,8 +323,8 @@ export default function AttendanceIndex({
function formatTime(dateStr: string | null): string { function formatTime(dateStr: string | null): string {
if (!dateStr) { if (!dateStr) {
return '-'; return '-';
} }
return format(new Date(dateStr), 'HH:mm'); return format(new Date(dateStr), 'HH:mm');
} }
@ -305,167 +338,168 @@ return '-';
<> <>
<Head title="Presensi" /> <Head title="Presensi" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6"> <div className="flex min-h-full flex-1 flex-col gap-6 overflow-auto p-4 md:p-6">
<div> <div>
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
Presensi Presensi
</h2> </h2>
</div> </div>
{/* Alert Status Presensi */} {!isAdmin && (
<Alert> <Alert>
<CalendarCheck className="h-4 w-4" /> <CalendarCheck className="h-4 w-4" />
<AlertTitle>Presensi Hari Ini</AlertTitle> <AlertTitle>Presensi Hari Ini</AlertTitle>
<AlertDescription> <AlertDescription>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{hasCheckedIn ? ( {hasCheckedIn ? (
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" /> <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Masuk: Masuk:
</span> </span>
<span className="font-medium"> <span className="font-medium">
{formatTime( {formatTime(
todayAttendance?.check_in_at, 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>
</>
)} )}
</span> </div>
</div> </div>
<div className="flex items-center gap-1.5"> ) : (
{hasCheckedOut ? ( <p className="text-sm text-muted-foreground">
<> Anda belum melakukan presensi hari ini
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" /> </p>
<span className="text-muted-foreground"> )}
Pulang: </div>
</span>
<span className="font-medium"> <div className="flex items-center gap-2">
{formatTime( {locationLoading && (
todayAttendance?.check_out_at, <span className="text-xs text-muted-foreground">
)} Mendapatkan lokasi...
</span> </span>
</> )}
) : ( <Button
<> size="sm"
<Clock className="h-3.5 w-3.5 text-orange-500" /> onClick={handleCheckIn}
<span className="text-muted-foreground"> disabled={hasCheckedIn || locationLoading}
Pulang: >
</span> <LogIn className="mr-1.5 h-3.5 w-3.5" />
<span className="font-medium text-orange-600"> Presensi Masuk
Belum </Button>
</span> <Button
</> size="sm"
)} variant="outline"
</div> onClick={handleCheckOut}
</div> disabled={
) : ( !hasCheckedIn ||
<p className="text-sm text-muted-foreground"> hasCheckedOut ||
Anda belum melakukan presensi hari ini locationLoading
}
>
<LogOut className="mr-1.5 h-3.5 w-3.5" />
Presensi Pulang
</Button>
</div>
</div>
</AlertDescription>
</Alert>
)}
{monthStats && (
<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>
)} <p className="text-lg font-bold">
</div> {monthStats.working_days}
</p>
<div className="flex items-center gap-2"> </div>
{locationLoading && ( </CardContent>
<span className="text-xs text-muted-foreground"> </Card>
Mendapatkan lokasi... <Card>
</span> <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">
<Button <CheckCircle2 className="h-5 w-5 text-green-600" />
size="sm" </div>
onClick={handleCheckIn} <div>
disabled={hasCheckedIn || locationLoading} <p className="text-xs text-muted-foreground">
> Hadir
<LogIn className="mr-1.5 h-3.5 w-3.5" /> </p>
Presensi Masuk <p className="text-lg font-bold">
</Button> {monthStats.present}
<Button </p>
size="sm" </div>
variant="outline" </CardContent>
onClick={handleCheckOut} </Card>
disabled={ <Card>
!hasCheckedIn || <CardContent className="flex items-center gap-3 py-3">
hasCheckedOut || <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-100">
locationLoading <UserX className="h-5 w-5 text-red-600" />
} </div>
> <div>
<LogOut className="mr-1.5 h-3.5 w-3.5" /> <p className="text-xs text-muted-foreground">
Presensi Pulang Tidak Hadir
</Button> </p>
</div> <p className="text-lg font-bold">
</div> {monthStats.absent}
</AlertDescription> </p>
</Alert> </div>
</CardContent>
{/* Summary Stats */} </Card>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <Card>
<Card> <CardContent className="flex items-center gap-3 py-3">
<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">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100"> <Wallet className="h-5 w-5 text-amber-600" />
<CalendarDays className="h-5 w-5 text-blue-600" /> </div>
</div> <div>
<div> <p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground"> Cuti
Hari Kerja </p>
</p> <p className="text-lg font-bold">
<p className="text-lg font-bold"> {monthStats.leave}
{monthStats.working_days} </p>
</p> </div>
</div> </CardContent>
</CardContent> </Card>
</Card> </div>
<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 <Card
className="overflow-hidden" className="overflow-hidden"
style={{ '--card-spacing': '0px' } as React.CSSProperties} 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 justify-between border-b px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border"> <div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
@ -503,10 +537,7 @@ return '-';
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 px-3 text-xs font-medium" className="h-8 px-3 text-xs font-medium"
onClick={() => { onClick={handleGoToToday}
setViewDate(new Date());
setSelectedDate(new Date());
}}
> >
Hari ini Hari ini
</Button> </Button>
@ -521,7 +552,6 @@ return '-';
</div> </div>
</div> </div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 border-b"> <div className="grid grid-cols-7 border-b">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<div <div
@ -533,11 +563,10 @@ return '-';
))} ))}
</div> </div>
{/* Calendar Grid */}
<div className="grid grid-cols-7"> <div className="grid grid-cols-7">
{calendarDays.map((cell, idx) => { {calendarDays.map((cell, idx) => {
const dateStr = format(cell.date, 'yyyy-MM-dd'); const dateStr = format(cell.date, 'yyyy-MM-dd');
const attendance = attendanceDates.get(dateStr); const dayAttendances = attendanceByDate.get(dateStr) ?? [];
const isSelected = isSameDay( const isSelected = isSameDay(
cell.date, cell.date,
selectedDate, selectedDate,
@ -547,47 +576,33 @@ return '-';
new Date(), new Date(),
); );
const today = new Date(); const todayMidnight = new Date();
today.setHours(0, 0, 0, 0); todayMidnight.setHours(0, 0, 0, 0);
const cellDate = new Date(cell.date); const cellDate = new Date(cell.date);
cellDate.setHours(0, 0, 0, 0); cellDate.setHours(0, 0, 0, 0);
const isPastDate = cellDate < today; const isPastDate = cellDate < todayMidnight;
const showAbsent = const showAbsent =
cell.isCurrentMonth && cell.isCurrentMonth &&
isPastDate && isPastDate &&
!isWeekend(cell.date) && !isWeekend(cell.date) &&
!attendance; dayAttendances.length === 0;
const late = attendance
? isLate(
attendance.check_in_at,
officeHour,
officeMinute,
)
: false;
const lateMins = attendance
? getLateMinutes(
attendance.check_in_at,
officeHour,
officeMinute,
)
: 0;
return ( return (
<button <div
key={idx} key={idx}
onClick={() => { onClick={() => {
setSelectedDate(cell.date); setSelectedDate(cell.date);
if (!isAdmin && dayAttendances.length > 0) {
if (attendance) { setDetailAttendance(dayAttendances[0]);
setDetailAttendance(attendance); }
}
}} }}
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${ className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
!cell.isCurrentMonth !cell.isCurrentMonth
? 'bg-muted/30 text-muted-foreground/50' ? 'bg-muted/30 text-muted-foreground/50'
: '' : ''
} ${isSelected ? 'bg-muted/50' : ''} ${
!isAdmin && dayAttendances.length > 0 ? 'cursor-pointer' : ''
}`} }`}
style={{ style={{
borderRight: '1px solid var(--border)', borderRight: '1px solid var(--border)',
@ -607,43 +622,89 @@ setDetailAttendance(attendance);
{cell.day} {cell.day}
</span> </span>
</div> </div>
<div className="mt-1 flex flex-col gap-0.5"> <div className="mt-1 flex flex-col gap-0.5 overflow-hidden">
{attendance && ( {isAdmin ? (
<> <>
<span {dayAttendances.map((att) => {
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'}`} const late = isLate(
> att.check_in_at,
{late officeHour,
? 'Terlambat' officeMinute,
: 'Hadir'} );
</span>
<span className="text-[10px] text-muted-foreground"> return (
Masuk :{' '} <button
{formatTime( key={att.id}
attendance.check_in_at, onClick={(e) => {
)} e.stopPropagation();
</span> setDetailAttendance(att);
<span className="text-[10px] text-muted-foreground"> }}
Pulang :{' '} className="w-full"
{formatTime( >
attendance.check_out_at, <Badge
)} variant={late ? 'destructive' : 'default'}
</span> className="w-full justify-center cursor-pointer truncate"
{late && ( >
<span className="text-[10px] text-yellow-600"> {att.employee_name}
Telat :{' '} </Badge>
{formatMinutes( </button>
lateMins, );
)}{' '} })}
menit </>
</span> ) : (
)} <>
<span className="text-[10px] text-muted-foreground"> {dayAttendances.length > 0 && (() => {
Jam Kerja :{' '} const att = dayAttendances[0];
{formatMinutes( const late = isLate(
attendance.work_duration_minutes, att.check_in_at,
)} officeHour,
</span> officeMinute,
);
const lateMins = getLateMinutes(
att.check_in_at,
officeHour,
officeMinute,
);
return (
<>
<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(
att.check_in_at,
)}
</span>
<span className="text-[10px] text-muted-foreground">
Pulang :{' '}
{formatTime(
att.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(
att.work_duration_minutes,
)}
</span>
</>
);
})()}
</> </>
)} )}
{showAbsent && ( {showAbsent && (
@ -652,7 +713,7 @@ setDetailAttendance(attendance);
</span> </span>
)} )}
</div> </div>
</button> </div>
); );
})} })}
</div> </div>
@ -673,6 +734,9 @@ setDetailAttendance(attendance);
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isAdmin && detailAttendance?.employee_name
? `${detailAttendance.employee_name} - `
: ''}
Detail Presensi -{' '} Detail Presensi -{' '}
{detailAttendance && {detailAttendance &&
format( format(
@ -749,19 +813,37 @@ setDetailAttendance(attendance);
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
<span className="text-sm font-medium text-muted-foreground"> <div className="flex flex-col gap-2">
Lokasi Presensi <span className="text-sm font-medium text-muted-foreground">
</span> Lokasi Masuk
<LocationMap </span>
latitude={ <LocationMap
detailAttendance.check_in_latitude latitude={
} detailAttendance.check_in_latitude
longitude={ }
detailAttendance.check_in_longitude longitude={
} detailAttendance.check_in_longitude
height="200px" }
/> height="200px"
/>
</div>
{detailAttendance.check_out_latitude && detailAttendance.check_out_longitude && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">
Lokasi Pulang
</span>
<LocationMap
latitude={
detailAttendance.check_out_latitude
}
longitude={
detailAttendance.check_out_longitude
}
height="200px"
/>
</div>
)}
</div> </div>
</div> </div>
)} )}