dstpabuaran.com/resources/js/pages/admin/hr/attendance/index.tsx

781 lines
35 KiB
TypeScript

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 {
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 {
ChevronLeft,
ChevronRight,
Clock,
LogIn,
LogOut,
} 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;
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;
};
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,
leaves,
employees = [],
todayAttendance,
currentYear,
currentMonth,
hrSettings,
isOnLeave,
canCheckIn,
isAdmin,
}: 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 attendanceByDate = useMemo(() => {
const map = new Map<string, Attendance[]>();
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<string, Leave[]>();
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);
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);
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 (
<>
<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>
{!isAdmin && (
<TodayAttendanceAlert
todayAttendance={todayAttendance}
isOnLeave={isOnLeave}
canCheckIn={canCheckIn}
locationLoading={locationLoading}
onCheckIn={handleCheckIn}
onCheckOut={handleCheckOut}
/>
)}
<Card className="py-0!">
<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 text-muted-foreground uppercase">
{format(today, 'MMM', {
locale: id,
})}
</span>
</div>
<div className="px-3 py-1">
<span className="text-lg font-bold text-primary">
{format(today, '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={handleGoToToday}
>
Hari ini
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{isAdmin && (
<div className="flex items-center gap-3 border-b px-6 py-2 text-xs text-muted-foreground">
<Badge variant="default" className="text-[10px]">Hadir</Badge>
<Badge variant="destructive" className="text-[10px]">Terlambat</Badge>
<Badge className="text-[10px] bg-purple-100 text-purple-700 hover:bg-purple-100">Cuti</Badge>
</div>
)}
<div className="overflow-x-auto">
<div className="grid min-w-[700px] 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>
<div className="grid min-w-[700px] grid-cols-7">
{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 [cYear, cMonth, cDay] = String(cell.date).split('-').map(Number);
const cellDate = new Date(cYear, cMonth - 1, cDay);
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 (
<div
key={idx}
onClick={() => {
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)',
}}
>
<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 overflow-hidden">
{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 (
<Badge
key={emp.id}
variant={late ? 'destructive' : 'default'}
className="w-full justify-center cursor-pointer truncate"
onClick={(e) => {
e.stopPropagation();
setDetailAttendance(att);
}}
>
{emp.name}
</Badge>
);
}
if (leave) {
return (
<Badge
key={emp.id}
className="w-full justify-center cursor-pointer truncate bg-purple-100 text-purple-700 hover:bg-purple-100"
>
{emp.name}
</Badge>
);
}
if (cell.isCurrentMonth && !isFutureDate) {
return (
<Badge
key={emp.id}
variant="outline"
className="w-full justify-center truncate text-muted-foreground"
>
{emp.name}
</Badge>
);
}
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 (
<>
<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>
</>
);
})()}
</>
)}
{!isAdmin && 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>
)}
{!isAdmin && dayLeaves.map((leave) => (
<span
key={leave.id}
className="inline-flex items-center justify-center rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 truncate"
>
Cuti
</span>
))}
</div>
</div>
);
})}
</div>
</div>
</Card>
</div>
{showCamera && (
<CameraCapture
onCapture={handleCameraCapture}
onClose={() => setShowCamera(false)}
/>
)}
<Dialog
open={!!detailAttendance}
onOpenChange={(open) => !open && setDetailAttendance(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isAdmin && detailAttendance?.employee_name
? `${detailAttendance.employee_name} - `
: ''}
{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-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">
Lokasi Masuk
</span>
<LocationMap
latitude={
detailAttendance.check_in_latitude
}
longitude={
detailAttendance.check_in_longitude
}
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>
)}
</DialogContent>
</Dialog>
</>
);
}