feat: enhance attendance index with leave tracking and employee details

This commit is contained in:
Yoga Pangestu 2026-08-07 15:27:48 +07:00
parent e4d956843c
commit 14140c39cc
3 changed files with 365 additions and 241 deletions

View File

@ -24,11 +24,13 @@ public function index(Request $request): Response
$month = $request->integer('month', now()->month); $month = $request->integer('month', now()->month);
$hrSettings = app(HRSettings::class); $hrSettings = app(HRSettings::class);
$user = auth()->user(); $user = auth()->user();
$isAdmin = $user->hasAnyRole(['developer', 'owner', 'direktur']); $isAdmin = $user->hasAnyRole(['developer', 'owner']);
if ($isAdmin) { if ($isAdmin) {
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),
'leaves' => $this->service->getLeavesByMonth($year, $month),
'employees' => $this->service->getAllEmployees(),
'todayAttendance' => null, 'todayAttendance' => null,
'currentYear' => $year, 'currentYear' => $year,
'currentMonth' => $month, 'currentMonth' => $month,
@ -42,9 +44,11 @@ public function index(Request $request): Response
} }
$employeeId = $user->employee?->id; $employeeId = $user->employee?->id;
$isOnLeave = $this->service->isOnLeave($user);
return Inertia::render('admin/hr/attendance/index', [ return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month, $employeeId), 'attendances' => $this->service->getByMonth($year, $month, $employeeId),
'leaves' => $this->service->getLeavesByMonth($year, $month, $employeeId),
'todayAttendance' => $this->service->getToday(), 'todayAttendance' => $this->service->getToday(),
'currentYear' => $year, 'currentYear' => $year,
'currentMonth' => $month, 'currentMonth' => $month,
@ -53,6 +57,8 @@ public function index(Request $request): Response
'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,
], ],
'isOnLeave' => $isOnLeave,
'canCheckIn' => $user->employee !== null,
'isAdmin' => false, 'isAdmin' => false,
]); ]);
} }
@ -60,7 +66,7 @@ public function index(Request $request): Response
public function store(AttendanceRequest $request): RedirectResponse public function store(AttendanceRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->checkIn($request->validated()), fn() => $this->service->checkIn($request->validated()),
'Berhasil check-in.', 'Berhasil check-in.',
'admin.hr.attendances.index' 'admin.hr.attendances.index'
); );
@ -69,7 +75,7 @@ public function store(AttendanceRequest $request): RedirectResponse
public function update(AttendanceRequest $request, Attendance $attendance): RedirectResponse public function update(AttendanceRequest $request, Attendance $attendance): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->checkOut($attendance, $request->validated()), fn() => $this->service->checkOut($attendance, $request->validated()),
'Berhasil check-out.', 'Berhasil check-out.',
'admin.hr.attendances.index' 'admin.hr.attendances.index'
); );

View File

@ -5,6 +5,7 @@
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee; use App\Models\Employee;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\User;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
@ -53,54 +54,161 @@ public function getToday(): ?array
return $this->getByDate(now()->toDateString()); 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 public function getAllEmployees(): Collection
{ {
return Employee::with(['user.userProfile', 'user.roles']) return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true)) ->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 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();
$today = Carbon::today();
$statEnd = $endOfMonth->lte($today) ? $endOfMonth : $today;
$workingDays = 0; $workingDays = 0;
$current = $startOfMonth->copy(); $current = $startOfMonth->copy();
while ($current->lte($endOfMonth)) { while ($current->lte($statEnd)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) { if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++; $workingDays++;
} }
$current->addDay(); $current->addDay();
} }
$attendanceQuery = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month);
if ($employeeId) { 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() $leaveQuery = LeaveRequest::approved()
->where('start_date', '<=', $endOfMonth) ->where('start_date', '<=', $statEnd)
->where('end_date', '>=', $startOfMonth); ->where('end_date', '>=', $startOfMonth)
if ($employeeId) { ->where('employee_id', $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;
return $carry + max(0, $days); $leaveRequests = $leaveQuery->get();
}, 0); $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 [ return [
'working_days' => $workingDays, 'working_days' => $workingDays,
'present' => $attendanceCount, '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, 'leave' => $leaveDays,
'total_employees' => $totalEmployees,
]; ];
} }

View File

@ -2,7 +2,7 @@ import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert';
import { CameraCapture, LocationMap } from '@/components/inputs'; import { CameraCapture, LocationMap } from '@/components/inputs';
import { Badge } from '@/components/ui/badge'; 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 } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,21 +18,18 @@ import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns'; import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale'; import { id } from 'date-fns/locale';
import { import {
CalendarDays,
CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock, Clock,
LogIn, LogIn,
LogOut, LogOut,
UserX,
Wallet,
} from 'lucide-react'; } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
type Attendance = { type Attendance = {
id: number; id: number;
employee_id: number;
attendance_date: string; attendance_date: string;
check_in_at: string | null; check_in_at: string | null;
check_out_at: string | null; check_out_at: string | null;
@ -46,23 +43,34 @@ type Attendance = {
employee_name: string; employee_name: string;
}; };
type MonthStats = { type Leave = {
working_days: number; id: number;
present: number; employee_id: number;
absent: number; start_date: string;
leave: number; end_date: string;
total_days: number;
status: string;
employee_name: string;
};
type Employee = {
id: number;
name: string;
}; };
type Props = { type Props = {
attendances: Attendance[]; attendances: Attendance[];
leaves: Leave[];
employees?: Employee[];
todayAttendance: Attendance | null; todayAttendance: Attendance | null;
currentYear: number; currentYear: number;
currentMonth: number; currentMonth: number;
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;
}; };
isOnLeave: boolean;
canCheckIn: boolean;
isAdmin: boolean; isAdmin: boolean;
}; };
@ -139,11 +147,14 @@ function formatMinutes(minutes: number | null): string {
export default function AttendanceIndex({ export default function AttendanceIndex({
attendances, attendances,
leaves,
employees = [],
todayAttendance, todayAttendance,
currentYear, currentYear,
currentMonth, currentMonth,
monthStats,
hrSettings, hrSettings,
isOnLeave,
canCheckIn,
isAdmin, isAdmin,
}: Props) { }: Props) {
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
@ -176,6 +187,24 @@ export default function AttendanceIndex({
return map; return map;
}, [attendances]); }, [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 calendarDays = useMemo(() => {
const daysInMonth = getDaysInMonth(viewYear, viewMonth); const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfMonth(viewYear, viewMonth); const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
@ -343,76 +372,14 @@ export default function AttendanceIndex({
{!isAdmin && ( {!isAdmin && (
<TodayAttendanceAlert <TodayAttendanceAlert
todayAttendance={todayAttendance} todayAttendance={todayAttendance}
isOnLeave={isOnLeave}
canCheckIn={canCheckIn}
locationLoading={locationLoading} locationLoading={locationLoading}
onCheckIn={handleCheckIn} onCheckIn={handleCheckIn}
onCheckOut={handleCheckOut} onCheckOut={handleCheckOut}
/> />
)} )}
{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 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="py-0!"> <Card className="py-0!">
<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">
@ -467,6 +434,14 @@ export default function AttendanceIndex({
</div> </div>
</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="overflow-x-auto">
<div className="grid min-w-[700px] grid-cols-7 border-b"> <div className="grid min-w-[700px] grid-cols-7 border-b">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
@ -481,154 +456,190 @@ export default function AttendanceIndex({
<div className="grid min-w-[700px] grid-cols-7"> <div className="grid min-w-[700px] 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 dayAttendances = attendanceByDate.get(dateStr) ?? []; const dayAttendances = attendanceByDate.get(dateStr) ?? [];
const isSelected = isSameDay( const dayLeaves = leavesByDate.get(dateStr) ?? [];
cell.date, const isSelected = isSameDay(
selectedDate, cell.date,
); selectedDate,
const isTodayDate = isSameDay( );
cell.date, const isTodayDate = isSameDay(
new Date(), cell.date,
); new Date(),
);
const todayMidnight = new Date(); const todayMidnight = new Date();
todayMidnight.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 < todayMidnight; const isPastDate = cellDate < todayMidnight;
const showAbsent = const isFutureDate = cellDate > todayMidnight;
cell.isCurrentMonth && const showAbsent =
isPastDate && cell.isCurrentMonth &&
!isWeekend(cell.date) && isPastDate &&
dayAttendances.length === 0; !isWeekend(cell.date) &&
dayAttendances.length === 0 &&
dayLeaves.length === 0;
return ( return (
<div <div
key={idx} key={idx}
onClick={() => { onClick={() => {
setSelectedDate(cell.date); setSelectedDate(cell.date);
if (!isAdmin && dayAttendances.length > 0) { if (!isAdmin && dayAttendances.length > 0) {
setDetailAttendance(dayAttendances[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 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' ? 'bg-muted/30 text-muted-foreground/50'
: '' : ''
} ${isSelected ? 'bg-muted/50' : ''} ${!isAdmin && dayAttendances.length > 0 ? 'cursor-pointer' : '' } ${isSelected ? 'bg-muted/50' : ''} ${!isAdmin && dayAttendances.length > 0 ? 'cursor-pointer' : ''
}`} }`}
style={{ style={{
borderRight: '1px solid var(--border)', borderRight: '1px solid var(--border)',
borderBottom: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
}} }}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<span <span
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${isSelected className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${isSelected
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: isTodayDate : isTodayDate
? 'bg-muted text-foreground' ? 'bg-muted text-foreground'
: 'text-foreground' : 'text-foreground'
}`} }`}
> >
{cell.day} {cell.day}
</span>
</div>
<div className="mt-1 flex flex-col gap-0.5 overflow-hidden">
{isAdmin ? (
<>
{dayAttendances.map((att) => {
const late = isLate(
att.check_in_at,
officeHour,
officeMinute,
);
return (
<button
key={att.id}
onClick={(e) => {
e.stopPropagation();
setDetailAttendance(att);
}}
className="w-full"
>
<Badge
variant={late ? 'destructive' : 'default'}
className="w-full justify-center cursor-pointer truncate"
>
{att.employee_name}
</Badge>
</button>
);
})}
</>
) : (
<>
{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>
</>
);
})()}
</>
)}
{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> </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> );
);
})} })}
</div> </div>
</div> </div>
@ -652,7 +663,6 @@ export default function AttendanceIndex({
{isAdmin && detailAttendance?.employee_name {isAdmin && detailAttendance?.employee_name
? `${detailAttendance.employee_name} - ` ? `${detailAttendance.employee_name} - `
: ''} : ''}
Detail Presensi -{' '}
{detailAttendance && {detailAttendance &&
format( format(
new Date(detailAttendance.attendance_date), new Date(detailAttendance.attendance_date),