Add tests for CheckAttendancePenaltiesJob to validate attendance penalties logic
- Implement tests to ensure job skips execution on weekends, when penalties are zero, or when no payroll period exists. - Validate late penalty creation for late check-ins and ensure no penalties for on-time or early check-ins. - Test absent penalties for employees without attendance records. - Ensure no duplicate penalties are created and that payroll recalculations are accurate after penalties are applied. - Handle multiple employees and edge cases, including employees without associated users. - Verify that the job can be dispatched to the queue and has the correct retry configuration.
This commit is contained in:
parent
d07eb3498a
commit
67e5a6271f
65
app/Http/Controllers/Admin/HR/AttendanceController.php
Normal file
65
app/Http/Controllers/Admin/HR/AttendanceController.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\HR\AttendanceRequest;
|
||||
use App\Models\Attendance;
|
||||
use App\Services\Admin\HR\AttendanceService;
|
||||
use App\Settings\HRSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceService $service
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$month = $request->integer('month', now()->month);
|
||||
|
||||
$hrSettings = app(HRSettings::class);
|
||||
|
||||
return Inertia::render('admin/hr/attendance/index', [
|
||||
'attendances' => $this->service->getByMonth($year, $month),
|
||||
'todayAttendance' => $this->service->getToday(),
|
||||
'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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(AttendanceRequest $request): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->checkIn($request->validated()),
|
||||
'Berhasil check-in.',
|
||||
'admin.hr.attendances.index'
|
||||
);
|
||||
}
|
||||
|
||||
public function update(AttendanceRequest $request, Attendance $attendance): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->checkOut($attendance, $request->validated()),
|
||||
'Berhasil check-out.',
|
||||
'admin.hr.attendances.index'
|
||||
);
|
||||
}
|
||||
|
||||
public function byDate(Request $request): ?array
|
||||
{
|
||||
$date = $request->query('date', now()->toDateString());
|
||||
|
||||
return $this->service->getByDate($date);
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Admin/HR/AttendanceRequest.php
Normal file
32
app/Http/Requests/Admin/HR/AttendanceRequest.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\HR;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Override;
|
||||
|
||||
class AttendanceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'photo' => ['required'],
|
||||
'latitude' => ['required', 'numeric'],
|
||||
'longitude' => ['required', 'numeric'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'photo' => 'foto',
|
||||
'latitude' => 'lintang',
|
||||
'longitude' => 'bujur',
|
||||
];
|
||||
}
|
||||
}
|
||||
184
app/Jobs/CheckAttendancePenaltiesJob.php
Normal file
184
app/Jobs/CheckAttendancePenaltiesJob.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollAdjustment;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Models\User;
|
||||
use App\Settings\HRSettings;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CheckAttendancePenaltiesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$yesterday = now()->subDay();
|
||||
$dayOfWeek = (int) $yesterday->format('w');
|
||||
|
||||
if ($dayOfWeek === 0 || $dayOfWeek === 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hrSettings = app(HRSettings::class);
|
||||
|
||||
if ($hrSettings->late_penalty_amount <= 0 && $hrSettings->absent_penalty_amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$year = $yesterday->year;
|
||||
$month = $yesterday->month;
|
||||
|
||||
$period = PayrollPeriod::where('year', $year)
|
||||
->where('month', $month)
|
||||
->first();
|
||||
|
||||
if (! $period) {
|
||||
return;
|
||||
}
|
||||
|
||||
$systemUser = User::first();
|
||||
|
||||
if (! $systemUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
$employees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))
|
||||
->where(fn ($q) => $q->whereNull('resign_date')->orWhere('resign_date', '>=', $yesterday->toDateString()))
|
||||
->get();
|
||||
|
||||
foreach ($employees as $employee) {
|
||||
$payroll = Payroll::where('payroll_period_id', $period->id)
|
||||
->where('employee_id', $employee->id)
|
||||
->where('status', PayrollStatus::UNPAID)
|
||||
->first();
|
||||
|
||||
if (! $payroll) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attendance = Attendance::where('employee_id', $employee->id)
|
||||
->where('attendance_date', $yesterday->toDateString())
|
||||
->first();
|
||||
|
||||
if ($attendance) {
|
||||
$this->handleLate($attendance, $payroll, $hrSettings, $systemUser, $yesterday);
|
||||
} else {
|
||||
$this->handleAbsent($payroll, $hrSettings, $systemUser, $yesterday, $employee);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleLate(
|
||||
Attendance $attendance,
|
||||
Payroll $payroll,
|
||||
HRSettings $hrSettings,
|
||||
User $systemUser,
|
||||
CarbonInterface $date
|
||||
): void {
|
||||
if ($hrSettings->late_penalty_amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$checkInTime = $attendance->check_in_at;
|
||||
[$officeHour, $officeMinute] = explode(':', $hrSettings->scheduled_check_in_time);
|
||||
$officeStart = $date->copy()->setTime((int) $officeHour, (int) $officeMinute, 0);
|
||||
|
||||
if (! $checkInTime || $checkInTime->lte($officeStart)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lateMinutes = (int) $checkInTime->diffInMinutes($officeStart);
|
||||
|
||||
$existing = PayrollAdjustment::where('payroll_id', $payroll->id)
|
||||
->where('attendance_id', $attendance->id)
|
||||
->where('type', PayrollAdjustmentType::DEDUCTION)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($payroll, $attendance, $hrSettings, $systemUser, $date, $lateMinutes) {
|
||||
PayrollAdjustment::create([
|
||||
'payroll_id' => $payroll->id,
|
||||
'created_by_id' => $systemUser->id,
|
||||
'attendance_id' => $attendance->id,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => $hrSettings->late_penalty_amount,
|
||||
'description' => 'Denda keterlambatan '.$date->format('d/m/Y').' - '.$lateMinutes.' menit',
|
||||
]);
|
||||
|
||||
$this->recalculatePayroll($payroll);
|
||||
});
|
||||
}
|
||||
|
||||
private function handleAbsent(
|
||||
Payroll $payroll,
|
||||
HRSettings $hrSettings,
|
||||
User $systemUser,
|
||||
CarbonInterface $date,
|
||||
Employee $employee
|
||||
): void {
|
||||
if ($hrSettings->absent_penalty_amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$description = 'Denda ketidakhadiran '.$date->format('d/m/Y');
|
||||
|
||||
$existing = PayrollAdjustment::where('payroll_id', $payroll->id)
|
||||
->where('type', PayrollAdjustmentType::DEDUCTION)
|
||||
->where('description', $description)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($payroll, $hrSettings, $systemUser, $description) {
|
||||
PayrollAdjustment::create([
|
||||
'payroll_id' => $payroll->id,
|
||||
'created_by_id' => $systemUser->id,
|
||||
'attendance_id' => null,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => $hrSettings->absent_penalty_amount,
|
||||
'description' => $description,
|
||||
]);
|
||||
|
||||
$this->recalculatePayroll($payroll);
|
||||
});
|
||||
}
|
||||
|
||||
private function recalculatePayroll(Payroll $payroll): void
|
||||
{
|
||||
$payroll->refresh();
|
||||
|
||||
$bonuses = $payroll->payrollAdjustments()
|
||||
->where('type', PayrollAdjustmentType::BONUS)
|
||||
->sum('amount');
|
||||
|
||||
$deductions = $payroll->payrollAdjustments()
|
||||
->where('type', PayrollAdjustmentType::DEDUCTION)
|
||||
->sum('amount');
|
||||
|
||||
$payroll->update([
|
||||
'bonus_amount' => $bonuses,
|
||||
'deduction_amount' => $deductions,
|
||||
'total_amount' => $payroll->base_salary + $bonuses - $deductions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -6,11 +6,13 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class Attendance extends Model
|
||||
class Attendance extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, InteractsWithMedia;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -18,6 +20,10 @@ protected function casts(): array
|
||||
'attendance_date' => 'date:Y-m-d',
|
||||
'check_in_at' => 'datetime',
|
||||
'check_out_at' => 'datetime',
|
||||
'check_in_latitude' => 'decimal:7',
|
||||
'check_in_longitude' => 'decimal:7',
|
||||
'check_out_latitude' => 'decimal:7',
|
||||
'check_out_longitude' => 'decimal:7',
|
||||
'work_duration_minutes' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
200
app/Services/Admin/HR/AttendanceService.php
Normal file
200
app/Services/Admin/HR/AttendanceService.php
Normal file
@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\HR;
|
||||
|
||||
use App\Enums\LeaveRequestStatus;
|
||||
use App\Models\Attendance;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Services\S3PresignedService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class AttendanceService
|
||||
{
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service = new S3PresignedService,
|
||||
) {}
|
||||
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Attendance::with(['employee.user.userProfile', 'media'])
|
||||
->latest('attendance_date')
|
||||
->get()
|
||||
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
|
||||
}
|
||||
|
||||
public function getByMonth(int $year, int $month): Collection
|
||||
{
|
||||
return Attendance::with(['employee.user.userProfile', 'media'])
|
||||
->whereYear('attendance_date', $year)
|
||||
->whereMonth('attendance_date', $month)
|
||||
->get()
|
||||
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
|
||||
}
|
||||
|
||||
public function getByDate(string $date): ?array
|
||||
{
|
||||
$attendance = Attendance::with(['employee.user.userProfile', 'media'])
|
||||
->where('attendance_date', $date)
|
||||
->where('employee_id', auth()->user()->employee?->id)
|
||||
->first();
|
||||
|
||||
return $attendance ? $this->formatAttendance($attendance) : null;
|
||||
}
|
||||
|
||||
public function getToday(): ?array
|
||||
{
|
||||
return $this->getByDate(now()->toDateString());
|
||||
}
|
||||
|
||||
public function getMonthStats(int $year, int $month): array
|
||||
{
|
||||
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
|
||||
$endOfMonth = $startOfMonth->copy()->endOfMonth();
|
||||
|
||||
$workingDays = 0;
|
||||
$current = $startOfMonth->copy();
|
||||
while ($current->lte($endOfMonth)) {
|
||||
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
|
||||
$workingDays++;
|
||||
}
|
||||
$current->addDay();
|
||||
}
|
||||
|
||||
$attendanceCount = Attendance::whereYear('attendance_date', $year)
|
||||
->whereMonth('attendance_date', $month)
|
||||
->count();
|
||||
|
||||
$leaveDays = LeaveRequest::approved()
|
||||
->where('start_date', '<=', $endOfMonth)
|
||||
->where('end_date', '>=', $startOfMonth)
|
||||
->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);
|
||||
}, 0);
|
||||
|
||||
return [
|
||||
'working_days' => $workingDays,
|
||||
'present' => $attendanceCount,
|
||||
'absent' => max(0, $workingDays - $attendanceCount - $leaveDays),
|
||||
'leave' => $leaveDays,
|
||||
];
|
||||
}
|
||||
|
||||
public function checkIn(array $data): Attendance
|
||||
{
|
||||
$employee = auth()->user()->employee;
|
||||
|
||||
if (! $employee) {
|
||||
throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
|
||||
}
|
||||
|
||||
$today = now()->toDateString();
|
||||
|
||||
$existing = Attendance::where('employee_id', $employee->id)
|
||||
->where('attendance_date', $today)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
throw new \Exception('Anda sudah melakukan presensi hari ini.');
|
||||
}
|
||||
|
||||
$attendance = Attendance::create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => $today,
|
||||
'check_in_at' => now(),
|
||||
'check_in_latitude' => $data['latitude'],
|
||||
'check_in_longitude' => $data['longitude'],
|
||||
]);
|
||||
|
||||
if (! empty($data['photo'])) {
|
||||
$this->registerMedia($attendance, $data['photo'], 'check-in');
|
||||
}
|
||||
|
||||
return $attendance;
|
||||
}
|
||||
|
||||
public function checkOut(Attendance $attendance, array $data): Attendance
|
||||
{
|
||||
$attendance->update([
|
||||
'check_out_at' => now(),
|
||||
'check_out_latitude' => $data['latitude'],
|
||||
'check_out_longitude' => $data['longitude'],
|
||||
]);
|
||||
|
||||
if (! empty($data['photo'])) {
|
||||
$this->registerMedia($attendance, $data['photo'], 'check-out');
|
||||
}
|
||||
|
||||
return $attendance;
|
||||
}
|
||||
|
||||
private function formatAttendance(Attendance $attendance): array
|
||||
{
|
||||
$toArray = $attendance->toArray();
|
||||
|
||||
if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) {
|
||||
$checkIn = \Carbon\Carbon::parse($attendance->check_in_at);
|
||||
$end = $checkIn->toDateString() === now()->toDateString()
|
||||
? now()
|
||||
: $checkIn->copy()->endOfDay();
|
||||
$toArray['work_duration_minutes'] = $checkIn->diffInMinutes($end);
|
||||
}
|
||||
|
||||
$checkInMedia = $attendance->getFirstMedia('check-in');
|
||||
$checkOutMedia = $attendance->getFirstMedia('check-out');
|
||||
|
||||
$toArray['check_in_photo'] = $checkInMedia
|
||||
? Cache::remember("attendance_check_in_{$checkInMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkInMedia->file_name))
|
||||
: null;
|
||||
|
||||
$toArray['check_out_photo'] = $checkOutMedia
|
||||
? Cache::remember("attendance_check_out_{$checkOutMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkOutMedia->file_name))
|
||||
: null;
|
||||
|
||||
return $toArray;
|
||||
}
|
||||
|
||||
private function registerMedia(Attendance $attendance, string $photo, string $type): void
|
||||
{
|
||||
if (str_starts_with($photo, 'data:image')) {
|
||||
$base64 = explode(',', $photo)[1];
|
||||
$imageData = base64_decode($base64);
|
||||
$filename = $type . '_' . time() . '_' . uniqid() . '.jpg';
|
||||
$s3Key = 'attendances/' . $filename;
|
||||
|
||||
app('filesystem')->disk('s3')->put($s3Key, $imageData);
|
||||
$mimeType = 'image/jpeg';
|
||||
$fileSize = strlen($imageData);
|
||||
} else {
|
||||
$s3Key = $photo;
|
||||
$filename = pathinfo($photo, PATHINFO_BASENAME);
|
||||
$mimeType = 'image/jpeg';
|
||||
$fileSize = 0;
|
||||
}
|
||||
|
||||
Media::create([
|
||||
'model_type' => Attendance::class,
|
||||
'model_id' => $attendance->id,
|
||||
'uuid' => Str::uuid(),
|
||||
'collection_name' => $type,
|
||||
'name' => $type,
|
||||
'file_name' => $s3Key,
|
||||
'mime_type' => $mimeType,
|
||||
'disk' => 's3',
|
||||
'conversions_disk' => 's3',
|
||||
'size' => $fileSize,
|
||||
'manipulations' => [],
|
||||
'custom_properties' => [],
|
||||
'generated_conversions' => [],
|
||||
'responsive_images' => [],
|
||||
'order_column' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
3746
package-lock.json
generated
3746
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -52,6 +52,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@ -62,6 +63,7 @@
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.7",
|
||||
|
||||
@ -13,8 +13,9 @@ import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
|
||||
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
|
||||
import { current as payrollCurrent, index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
|
||||
import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods';
|
||||
import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
|
||||
import { index as employeesIndex } from '@/routes/admin/hr/employees';
|
||||
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
|
||||
import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
||||
@ -83,7 +84,7 @@ const keuanganItems: NavMenuItem[] = [
|
||||
|
||||
const hrItems: NavMenuItem[] = [
|
||||
{ title: 'Pegawai', href: employeesIndex.url(), icon: UserCircle },
|
||||
{ title: 'Presensi', href: '#', icon: CalendarCheck },
|
||||
{ title: 'Presensi', href: attendancesIndex.url(), icon: CalendarCheck },
|
||||
{ title: 'Cuti', href: leaveRequestsIndex.url(), icon: CalendarDays },
|
||||
];
|
||||
|
||||
|
||||
123
resources/js/components/camera-capture.tsx
Normal file
123
resources/js/components/camera-capture.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Camera, RotateCcw, X } from 'lucide-react';
|
||||
|
||||
interface CameraCaptureProps {
|
||||
onCapture: (dataUrl: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: 640, height: 480 },
|
||||
});
|
||||
setStream(mediaStream);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = mediaStream;
|
||||
}
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Tidak dapat mengakses kamera. Pastikan izin kamera diberikan.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
startCamera();
|
||||
return () => {
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const capture = () => {
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.translate(canvas.width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.drawImage(video, 0, 0);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||
setCapturedImage(dataUrl);
|
||||
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
const retake = () => {
|
||||
setCapturedImage(null);
|
||||
startCamera();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (capturedImage) {
|
||||
onCapture(capturedImage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div className="relative w-full max-w-md rounded-lg bg-background p-4 shadow-lg">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Ambil Foto</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-center text-sm text-muted-foreground">{error}</p>
|
||||
<Button onClick={startCamera}>Coba Lagi</Button>
|
||||
</div>
|
||||
) : capturedImage ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<img src={capturedImage} alt="Captured" className="w-full rounded-lg" />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={retake}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Ulangi
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={confirm}>
|
||||
Gunakan Foto
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="relative overflow-hidden rounded-lg bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="w-full"
|
||||
style={{ transform: 'scaleX(-1)' }}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" onClick={capture}>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
Ambil Foto
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
resources/js/components/location-map.tsx
Normal file
50
resources/js/components/location-map.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface LocationMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
height?: string;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }: LocationMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
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: '© <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 <div ref={mapRef} style={{ height, width: '100%' }} className="rounded-lg" />;
|
||||
}
|
||||
526
resources/js/pages/admin/hr/attendance/index.tsx
Normal file
526
resources/js/pages/admin/hr/attendance/index.tsx
Normal file
@ -0,0 +1,526 @@
|
||||
import { CameraCapture } from '@/components/camera-capture';
|
||||
import { LocationMap } from '@/components/location-map';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } 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 { CalendarCheck, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock, LogIn, LogOut, UserX, Wallet } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Attendance = {
|
||||
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;
|
||||
};
|
||||
|
||||
type MonthStats = {
|
||||
working_days: number;
|
||||
present: number;
|
||||
absent: number;
|
||||
leave: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
attendances: Attendance[];
|
||||
todayAttendance: Attendance | null;
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
monthStats: MonthStats;
|
||||
hrSettings: {
|
||||
scheduled_check_in_time: string;
|
||||
scheduled_check_out_time: string;
|
||||
};
|
||||
};
|
||||
|
||||
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 checkIsToday(date: Date): boolean {
|
||||
const t = new Date();
|
||||
return date.getDate() === t.getDate() && date.getMonth() === t.getMonth() && date.getFullYear() === t.getFullYear();
|
||||
}
|
||||
|
||||
function isWeekend(date: Date): boolean {
|
||||
const day = date.getDay();
|
||||
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, todayAttendance, currentYear, currentMonth, monthStats, hrSettings }: 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 attendanceDates = useMemo(() => {
|
||||
const dates = new Map<string, Attendance>();
|
||||
attendances.forEach((att) => {
|
||||
dates.set(att.attendance_date, att);
|
||||
});
|
||||
return dates;
|
||||
}, [attendances]);
|
||||
|
||||
const selectedAttendance = useMemo(() => {
|
||||
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
||||
return attendanceDates.get(dateStr) ?? null;
|
||||
}, [selectedDate, attendanceDates]);
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
||||
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
||||
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 = () => setViewDate((d) => subMonths(d, 1));
|
||||
const handleNextMonth = () => setViewDate((d) => addMonths(d, 1));
|
||||
|
||||
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 hasCheckedIn = !!todayAttendance;
|
||||
const hasCheckedOut = !!todayAttendance?.check_out_at;
|
||||
|
||||
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>
|
||||
|
||||
{/* Alert Status Presensi */}
|
||||
<Alert>
|
||||
<CalendarCheck className="h-4 w-4" />
|
||||
<AlertTitle>Presensi Hari Ini</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{hasCheckedIn ? (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-muted-foreground">Masuk:</span>
|
||||
<span className="font-medium">{formatTime(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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Anda belum melakukan presensi hari ini</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{locationLoading && (
|
||||
<span className="text-xs text-muted-foreground">Mendapatkan lokasi...</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCheckIn}
|
||||
disabled={hasCheckedIn || locationLoading}
|
||||
>
|
||||
<LogIn className="mr-1.5 h-3.5 w-3.5" />
|
||||
Presensi Masuk
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCheckOut}
|
||||
disabled={!hasCheckedIn || hasCheckedOut || locationLoading}
|
||||
>
|
||||
<LogOut className="mr-1.5 h-3.5 w-3.5" />
|
||||
Presensi Pulang
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<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="overflow-hidden" 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 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 uppercase text-muted-foreground">
|
||||
{format(viewDate, 'MMM', { locale: id })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-1">
|
||||
<span className="text-lg font-bold text-primary">{format(viewDate, '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={() => { setViewDate(new Date()); setSelectedDate(new Date()); }}>
|
||||
Hari ini
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleNextMonth}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday Headers */}
|
||||
<div className="grid 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>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((cell, idx) => {
|
||||
const dateStr = format(cell.date, 'yyyy-MM-dd');
|
||||
const attendance = attendanceDates.get(dateStr);
|
||||
const isSelected = isSameDay(cell.date, selectedDate);
|
||||
const isTodayDate = isSameDay(cell.date, new Date());
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const cellDate = new Date(cell.date);
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const isPastDate = cellDate < today;
|
||||
const showAbsent = cell.isCurrentMonth && isPastDate && !isWeekend(cell.date) && !attendance;
|
||||
|
||||
const late = attendance ? isLate(attendance.check_in_at, officeHour, officeMinute) : false;
|
||||
const lateMins = attendance ? getLateMinutes(attendance.check_in_at, officeHour, officeMinute) : 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setSelectedDate(cell.date);
|
||||
if (attendance) setDetailAttendance(attendance);
|
||||
}}
|
||||
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' : ''
|
||||
}`}
|
||||
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">
|
||||
{attendance && (
|
||||
<>
|
||||
<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(attendance.check_in_at)}</span>
|
||||
<span className="text-[10px] text-muted-foreground">Pulang : {formatTime(attendance.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(attendance.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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showCamera && (
|
||||
<CameraCapture
|
||||
onCapture={handleCameraCapture}
|
||||
onClose={() => setShowCamera(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!detailAttendance} onOpenChange={(open) => !open && setDetailAttendance(null)}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Detail Presensi - {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-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Lokasi Presensi</span>
|
||||
<LocationMap
|
||||
latitude={detailAttendance.check_in_latitude}
|
||||
longitude={detailAttendance.check_in_longitude}
|
||||
height="200px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AttendanceIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'HR',
|
||||
href: attendanceIndex(),
|
||||
},
|
||||
{
|
||||
title: 'Presensi',
|
||||
href: attendanceIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Console\Commands\GeneratePayrollCommand;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use App\Jobs\CheckAttendancePenaltiesJob;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command(GeneratePayrollCommand::class)->monthlyOn(1, '00:00');
|
||||
Schedule::job(new CheckAttendancePenaltiesJob)->dailyAt('00:00');
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
use App\Http\Controllers\Admin\Master\ProductController;
|
||||
use App\Http\Controllers\Admin\Master\SupplierController;
|
||||
use App\Http\Controllers\Admin\HR\AttendanceController;
|
||||
use App\Http\Controllers\Admin\HR\EmployeeController;
|
||||
use App\Http\Controllers\Admin\HR\LeaveRequestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -75,6 +76,11 @@
|
||||
Route::resource('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit']);
|
||||
Route::post('leave-requests/{leaveRequest}/approve', [LeaveRequestController::class, 'approve'])->name('leave-requests.approve');
|
||||
Route::post('leave-requests/{leaveRequest}/reject', [LeaveRequestController::class, 'reject'])->name('leave-requests.reject');
|
||||
|
||||
Route::get('attendances', [AttendanceController::class, 'index'])->name('attendances.index');
|
||||
Route::post('attendances', [AttendanceController::class, 'store'])->name('attendances.store');
|
||||
Route::put('attendances/{attendance}', [AttendanceController::class, 'update'])->name('attendances.update');
|
||||
Route::get('attendances/by-date', [AttendanceController::class, 'byDate'])->name('attendances.by-date');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
733
tests/Feature/Admin/HR/AttendanceTest.php
Normal file
733
tests/Feature/Admin/HR/AttendanceTest.php
Normal file
@ -0,0 +1,733 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Employee;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| AUTHENTICATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('authenticated users can visit the attendance index page', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| INDEX PAGE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('index page displays attendances', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employeeUser = User::factory()->create();
|
||||
$employeeUser->employee()->create([
|
||||
'join_date' => '2024-01-01',
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
'check_in_at' => now()->setHour(8)->setMinute(0),
|
||||
'check_out_at' => now()->setHour(17)->setMinute(0),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/hr/attendance/index')
|
||||
->has('attendances', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page works with zero attendances', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/hr/attendance/index')
|
||||
->has('attendances', 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page passes current year and month', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('currentYear', now()->year)
|
||||
->where('currentMonth', now()->month)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page can filter by year and month', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employeeUser = User::factory()->create();
|
||||
$employeeUser->employee()->create([
|
||||
'join_date' => '2024-01-01',
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => '2024-03-15',
|
||||
'check_in_at' => '2024-03-15 08:00:00',
|
||||
'check_out_at' => '2024-03-15 17:00:00',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index', ['year' => 2024, 'month' => 3]));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('currentYear', 2024)
|
||||
->where('currentMonth', 3)
|
||||
->has('attendances', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page excludes attendances from other months', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employeeUser = User::factory()->create();
|
||||
$employeeUser->employee()->create([
|
||||
'join_date' => '2024-01-01',
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => '2024-03-15',
|
||||
'check_in_at' => '2024-03-15 08:00:00',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index', ['year' => 2024, 'month' => 4]));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('attendances', 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page passes hrSettings props', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('hrSettings.scheduled_check_in_time')
|
||||
->has('hrSettings.scheduled_check_out_time')
|
||||
);
|
||||
});
|
||||
|
||||
test('index page passes monthStats', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('monthStats.working_days')
|
||||
->has('monthStats.present')
|
||||
->has('monthStats.absent')
|
||||
->has('monthStats.leave')
|
||||
);
|
||||
});
|
||||
|
||||
test('index page passes todayAttendance as null when no attendance today', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('todayAttendance', null)
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| STORE / CHECK-IN
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('guest cannot check in', function () {
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('authenticated user without employee cannot check in', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
});
|
||||
|
||||
test('employee can check in successfully', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('attendances', [
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('employee cannot check in twice on the same day', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
'check_in_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('attendances', 1);
|
||||
});
|
||||
|
||||
test('check in requires photo', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('attendances', 0);
|
||||
});
|
||||
|
||||
test('check in requires latitude', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('attendances', 0);
|
||||
});
|
||||
|
||||
test('check in requires longitude', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('attendances', 0);
|
||||
});
|
||||
|
||||
test('check in latitude must be numeric', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => 'not-a-number',
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('attendances', 0);
|
||||
});
|
||||
|
||||
test('check in longitude must be numeric', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 'not-a-number',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('attendances', 0);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| UPDATE / CHECK-OUT
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('guest cannot check out', function () {
|
||||
$attendance = Attendance::factory()->create([
|
||||
'check_in_at' => now()->subHours(8),
|
||||
'check_out_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->put(route('admin.hr.attendances.update', $attendance), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('employee can check out successfully', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$attendance = Attendance::factory()->create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
'check_in_at' => now()->subHours(8),
|
||||
'check_out_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->put(route('admin.hr.attendances.update', $attendance), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$attendance->refresh();
|
||||
$this->assertNotNull($attendance->check_out_at);
|
||||
$this->assertEquals(-6.2088, $attendance->check_out_latitude);
|
||||
$this->assertEquals(106.8456, $attendance->check_out_longitude);
|
||||
});
|
||||
|
||||
test('check out requires photo', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$attendance = Attendance::factory()->create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
'check_in_at' => now()->subHours(8),
|
||||
'check_out_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->put(route('admin.hr.attendances.update', $attendance), [
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$attendance->refresh();
|
||||
$this->assertNull($attendance->check_out_at);
|
||||
});
|
||||
|
||||
test('check out requires latitude', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$attendance = Attendance::factory()->create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
'check_in_at' => now()->subHours(8),
|
||||
'check_out_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->put(route('admin.hr.attendances.update', $attendance), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$attendance->refresh();
|
||||
$this->assertNull($attendance->check_out_at);
|
||||
});
|
||||
|
||||
test('check out requires longitude', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$attendance = Attendance::factory()->create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
'check_in_at' => now()->subHours(8),
|
||||
'check_out_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->put(route('admin.hr.attendances.update', $attendance), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$attendance->refresh();
|
||||
$this->assertNull($attendance->check_out_at);
|
||||
});
|
||||
|
||||
test('updating non-existent attendance returns 404', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->put(route('admin.hr.attendances.update', 999999), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| BY-DATE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('guest cannot access by-date endpoint', function () {
|
||||
$response = $this->get(route('admin.hr.attendances.by-date'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('by-date returns empty when no attendance for date', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->getJson(route('admin.hr.attendances.by-date', ['date' => '2024-01-01']));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('by-date defaults to today when no date parameter', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->getJson(route('admin.hr.attendances.by-date'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MONTH STATS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('month stats calculates correct working days for january 2024', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index', ['year' => 2024, 'month' => 1]));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('monthStats.working_days', 23)
|
||||
);
|
||||
});
|
||||
|
||||
test('month stats present count matches attendance records', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employeeUser = User::factory()->create();
|
||||
$employeeUser->employee()->create([
|
||||
'join_date' => '2024-01-01',
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => '2024-01-15',
|
||||
'check_in_at' => '2024-01-15 08:00:00',
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => '2024-01-16',
|
||||
'check_in_at' => '2024-01-16 08:00:00',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index', ['year' => 2024, 'month' => 1]));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('monthStats.present', 2)
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MULTIPLE ATTENDANCES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('multiple employees can have attendances on the same day', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$emp1User = User::factory()->create();
|
||||
$emp1User->employee()->create([
|
||||
'join_date' => '2024-01-01',
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$emp2User = User::factory()->create();
|
||||
$emp2User->employee()->create([
|
||||
'join_date' => '2024-01-01',
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $emp1User->employee->id,
|
||||
'attendance_date' => '2024-01-15',
|
||||
'check_in_at' => '2024-01-15 08:00:00',
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $emp2User->employee->id,
|
||||
'attendance_date' => '2024-01-15',
|
||||
'check_in_at' => '2024-01-15 08:30:00',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index', ['year' => 2024, 'month' => 1]));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('attendances', 2)
|
||||
);
|
||||
});
|
||||
|
||||
test('employee can have multiple attendances across different days', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employeeUser = User::factory()->create();
|
||||
$employeeUser->employee()->create([
|
||||
'join_date' => '2024-01-01',
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => '2024-01-15',
|
||||
'check_in_at' => '2024-01-15 08:00:00',
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => '2024-01-16',
|
||||
'check_in_at' => '2024-01-16 08:00:00',
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $employeeUser->employee->id,
|
||||
'attendance_date' => '2024-01-17',
|
||||
'check_in_at' => '2024-01-17 08:00:00',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.hr.attendances.index', ['year' => 2024, 'month' => 1]));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('attendances', 3)
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DATA INTEGRITY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('attendance stores correct check-in coordinates', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$attendance = Attendance::where('employee_id', $employee->id)->first();
|
||||
$this->assertEquals(-6.2088, $attendance->check_in_latitude);
|
||||
$this->assertEquals(106.8456, $attendance->check_in_longitude);
|
||||
});
|
||||
|
||||
test('attendance has correct attendance_date', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('attendances', [
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => now()->toDateString(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('check-in sets check_out_at to null', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => now()->subMonth()->toDateString(),
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$this->post(route('admin.hr.attendances.store'), [
|
||||
'photo' => 'data:image/jpeg;base64,'.base64_encode('fake-image'),
|
||||
'latitude' => -6.2088,
|
||||
'longitude' => 106.8456,
|
||||
]);
|
||||
|
||||
$attendance = Attendance::where('employee_id', $employee->id)->first();
|
||||
$this->assertNull($attendance->check_out_at);
|
||||
});
|
||||
634
tests/Feature/Admin/HR/CheckAttendancePenaltiesJobTest.php
Normal file
634
tests/Feature/Admin/HR/CheckAttendancePenaltiesJobTest.php
Normal file
@ -0,0 +1,634 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Jobs\CheckAttendancePenaltiesJob;
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollAdjustment;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Models\User;
|
||||
use App\Settings\HRSettings;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HELPER
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function createActiveEmployeeWithPayroll(int $year, int $month, PayrollStatus|string $status = PayrollStatus::UNPAID): array
|
||||
{
|
||||
$period = PayrollPeriod::firstOrCreate(
|
||||
['year' => $year, 'month' => $month],
|
||||
['status' => 'open']
|
||||
);
|
||||
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => Carbon::createFromDate($year, $month, 1)->subMonth()->toDateString(),
|
||||
'resign_date' => null,
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
$payroll = Payroll::factory()->create([
|
||||
'payroll_period_id' => $period->id,
|
||||
'employee_id' => $employee->id,
|
||||
'base_salary' => $employee->base_salary,
|
||||
'bonus_amount' => 0,
|
||||
'deduction_amount' => 0,
|
||||
'total_amount' => $employee->base_salary,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return compact('period', 'user', 'employee', 'payroll');
|
||||
}
|
||||
|
||||
function setHrSettings(int $latePenalty, int $absentPenalty, string $checkInTime = '08:00'): void
|
||||
{
|
||||
$settings = app(HRSettings::class);
|
||||
$settings->scheduled_check_in_time = $checkInTime;
|
||||
$settings->scheduled_check_out_time = '17:00';
|
||||
$settings->late_penalty_amount = $latePenalty;
|
||||
$settings->absent_penalty_amount = $absentPenalty;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SKIP CONDITIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('job skips when yesterday is sunday', function () {
|
||||
$sunday = Carbon::parse('2024-07-07'); // Sunday
|
||||
Carbon::setTestNow($sunday->copy()->addDay());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($sunday->year, $sunday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $sunday->toDateString(),
|
||||
'check_in_at' => $sunday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips when yesterday is saturday', function () {
|
||||
$saturday = Carbon::parse('2024-07-06'); // Saturday
|
||||
Carbon::setTestNow($saturday->copy()->addDay());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($saturday->year, $saturday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $saturday->toDateString(),
|
||||
'check_in_at' => $saturday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips when both penalties are zero', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(0, 0);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips when no payroll period exists', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$employee = Employee::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'join_date' => $yesterday->copy()->subMonth()->toDateString(),
|
||||
'resign_date' => null,
|
||||
'employment_status' => 'full_time',
|
||||
'base_salary' => 5000000,
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips when no system user exists', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
User::query()->delete();
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips inactive employees', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
$data['user']->update(['is_active' => false]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips resigned employees', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
$data['employee']->update(['resign_date' => $yesterday->copy()->subDays(5)->toDateString()]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips when payroll is already paid', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month, PayrollStatus::PAID);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job skips when payroll is cancelled', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month, PayrollStatus::CANCELLED);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| LATE PENALTY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('job creates late penalty when employee checks in late', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(8)->setMinute(30),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseHas('payroll_adjustments', [
|
||||
'payroll_id' => $data['payroll']->id,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => 50000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('job does not create late penalty when employee checks in on time', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setTime(8, 0, 0),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job does not create late penalty when employee checks in early', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setTime(7, 30, 0),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('job does not create late penalty when late_penalty_amount is zero', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(0, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('late penalty description includes date and minutes', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(8)->setMinute(30),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
|
||||
expect($adjustment->description)->toContain('Denda keterlambatan');
|
||||
expect($adjustment->description)->toContain($yesterday->format('d/m/Y'));
|
||||
expect($adjustment->description)->toContain('30 menit');
|
||||
});
|
||||
|
||||
test('late penalty links to attendance record', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
$attendance = Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
|
||||
expect($adjustment->attendance_id)->toBe($attendance->id);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ABSENT PENALTY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('job creates absent penalty when employee has no attendance', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseHas('payroll_adjustments', [
|
||||
'payroll_id' => $data['payroll']->id,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => 100000,
|
||||
'attendance_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('job does not create absent penalty when absent_penalty_amount is zero', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 0);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 0);
|
||||
});
|
||||
|
||||
test('absent penalty description includes date', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
|
||||
expect($adjustment->description)->toContain('Denda ketidakhadiran');
|
||||
expect($adjustment->description)->toContain($yesterday->format('d/m/Y'));
|
||||
});
|
||||
|
||||
test('absent penalty has null attendance_id', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
|
||||
expect($adjustment->attendance_id)->toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DEDUPLICATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('job does not create duplicate late penalty', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
$attendance = Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
PayrollAdjustment::create([
|
||||
'payroll_id' => $data['payroll']->id,
|
||||
'created_by_id' => User::first()->id,
|
||||
'attendance_id' => $attendance->id,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => 50000,
|
||||
'description' => 'Denda keterlambatan manual',
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 1);
|
||||
});
|
||||
|
||||
test('job does not create duplicate absent penalty', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
PayrollAdjustment::create([
|
||||
'payroll_id' => $data['payroll']->id,
|
||||
'created_by_id' => User::first()->id,
|
||||
'attendance_id' => null,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => 100000,
|
||||
'description' => 'Denda ketidakhadiran '.$yesterday->format('d/m/Y'),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseCount('payroll_adjustments', 1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| PAYROLL RECALCULATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('job recalculates payroll total_amount after late penalty', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$data['payroll']->refresh();
|
||||
expect($data['payroll']->deduction_amount)->toBe(50000);
|
||||
expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary - 50000);
|
||||
});
|
||||
|
||||
test('job recalculates payroll total_amount after absent penalty', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$data['payroll']->refresh();
|
||||
expect($data['payroll']->deduction_amount)->toBe(100000);
|
||||
expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary - 100000);
|
||||
});
|
||||
|
||||
test('job recalculates payroll with existing bonus adjustments', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
PayrollAdjustment::create([
|
||||
'payroll_id' => $data['payroll']->id,
|
||||
'created_by_id' => User::first()->id,
|
||||
'attendance_id' => null,
|
||||
'type' => PayrollAdjustmentType::BONUS,
|
||||
'amount' => 200000,
|
||||
'description' => 'Bonus test',
|
||||
]);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$data['payroll']->refresh();
|
||||
expect($data['payroll']->bonus_amount)->toBe(200000);
|
||||
expect($data['payroll']->deduction_amount)->toBe(50000);
|
||||
expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary + 200000 - 50000);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MULTIPLE EMPLOYEES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('job processes multiple employees', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
|
||||
$data1 = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
$data2 = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data1['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseHas('payroll_adjustments', [
|
||||
'payroll_id' => $data1['payroll']->id,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => 50000,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('payroll_adjustments', [
|
||||
'payroll_id' => $data2['payroll']->id,
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => 100000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('job handles mix of late and absent employees', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000, '08:00');
|
||||
|
||||
$lateEmp = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
$absentEmp = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $lateEmp['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseHas('payroll_adjustments', [
|
||||
'payroll_id' => $lateEmp['payroll']->id,
|
||||
'amount' => 50000,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('payroll_adjustments', [
|
||||
'payroll_id' => $absentEmp['payroll']->id,
|
||||
'amount' => 100000,
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| EDGE CASES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('job handles employee with no user gracefully', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$this->assertDatabaseHas('payroll_adjustments', [
|
||||
'payroll_id' => $data['payroll']->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('job uses system user as created_by for adjustments', function () {
|
||||
$yesterday = now()->subDay();
|
||||
Carbon::setTestNow(now());
|
||||
|
||||
setHrSettings(50000, 100000);
|
||||
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
|
||||
|
||||
$systemUser = User::first();
|
||||
|
||||
Attendance::factory()->create([
|
||||
'employee_id' => $data['employee']->id,
|
||||
'attendance_date' => $yesterday->toDateString(),
|
||||
'check_in_at' => $yesterday->copy()->setHour(9),
|
||||
]);
|
||||
|
||||
(new CheckAttendancePenaltiesJob)->handle();
|
||||
|
||||
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
|
||||
expect($adjustment->created_by_id)->toBe($systemUser->id);
|
||||
});
|
||||
|
||||
test('job can be dispatched to queue', function () {
|
||||
Queue::fake();
|
||||
|
||||
CheckAttendancePenaltiesJob::dispatch();
|
||||
|
||||
Queue::assertPushed(CheckAttendancePenaltiesJob::class);
|
||||
});
|
||||
|
||||
test('job has correct tries property', function () {
|
||||
$job = new CheckAttendancePenaltiesJob;
|
||||
expect($job->tries)->toBe(3);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user