feat: enhance attendance index with leave tracking and employee details
This commit is contained in:
parent
e4d956843c
commit
14140c39cc
@ -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'
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user