diff --git a/app/Http/Controllers/Admin/AttendanceController.php b/app/Http/Controllers/Admin/AttendanceController.php index e798f3f..706ca55 100644 --- a/app/Http/Controllers/Admin/AttendanceController.php +++ b/app/Http/Controllers/Admin/AttendanceController.php @@ -10,36 +10,52 @@ class AttendanceController extends Controller { - public function index() + public function index(Request $request) { - $attendances = Attendance::with('employee')->orderBy('date', 'desc')->get(); + $query = Attendance::with('employee')->orderBy('date', 'desc'); - $attendances = $attendances->map(function ($attendance) { + if ($request->search) { + $query->whereHas('employee', function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('nip', 'like', '%' . $request->search . '%'); + }); + } + if ($request->date) { + $query->where('date', $request->date); + } + if ($request->status) { + $query->where('status', $request->status); + } + + $attendances = $query->get()->map(function ($attendance) { $totalHours = null; - // Menghitung selisih jam kerja dalam format H:i if ($attendance->check_in && $attendance->check_out) { $checkIn = Carbon::parse($attendance->check_in); $checkOut = Carbon::parse($attendance->check_out); - $diff = $checkIn->diff($checkOut); $totalHours = sprintf('%02d:%02d', $diff->h, $diff->i); } return [ - 'id' => $attendance->id, + 'id' => $attendance->id, 'employee_name' => $attendance->employee->name ?? '-', - 'employee_nik' => $attendance->employee->nip ?? '-', - 'date' => $attendance->date, - 'check_in' => $attendance->check_in, - 'check_out' => $attendance->check_out, - 'status' => $attendance->status, - 'notes' => $attendance->notes, - 'total_hours' => $totalHours, + 'employee_nik' => $attendance->employee->nip ?? '-', + 'date' => $attendance->date, + 'check_in' => $attendance->check_in, + 'check_out' => $attendance->check_out, + 'status' => $attendance->status, + 'notes' => $attendance->notes, + 'total_hours' => $totalHours, + 'latitude_in' => $attendance->latitude_in, + 'longitude_in' => $attendance->longitude_in, + 'latitude_out' => $attendance->latitude_out, + 'longitude_out'=> $attendance->longitude_out, ]; }); return Inertia::render('admin/attendances/index', [ - 'attendances' => $attendances + 'attendances' => $attendances, + 'filters' => $request->only(['search', 'date', 'status']), ]); } } diff --git a/app/Http/Controllers/Admin/DepartmentController.php b/app/Http/Controllers/Admin/DepartmentController.php index b782060..7e3593c 100644 --- a/app/Http/Controllers/Admin/DepartmentController.php +++ b/app/Http/Controllers/Admin/DepartmentController.php @@ -9,10 +9,15 @@ class DepartmentController extends Controller { - public function index() + public function index(Request $request) { + $query = Department::withCount('employees'); + if ($request->search) { + $query->where('name', 'like', '%' . $request->search . '%'); + } return Inertia::render('admin/departments/index', [ - 'departments' => Department::withCount('employees')->latest()->get(), + 'departments' => $query->latest()->get(), + 'filters' => $request->only(['search']), ]); } @@ -26,6 +31,10 @@ public function store(Request $request) $validated = $request->validate([ 'name' => 'required|string|max:255|unique:departments,name', 'description' => 'nullable|string', + ], [ + 'name.required' => 'Nama departemen wajib diisi.', + 'name.max' => 'Nama departemen maksimal 255 karakter.', + 'name.unique' => 'Nama departemen sudah terdaftar.', ]); Department::create($validated); @@ -46,6 +55,10 @@ public function update(Request $request, Department $department) $validated = $request->validate([ 'name' => 'required|string|max:255|unique:departments,name,' . $department->id, 'description' => 'nullable|string', + ], [ + 'name.required' => 'Nama departemen wajib diisi.', + 'name.max' => 'Nama departemen maksimal 255 karakter.', + 'name.unique' => 'Nama departemen sudah terdaftar.', ]); $department->update($validated); @@ -56,7 +69,7 @@ public function update(Request $request, Department $department) public function destroy(Department $department) { - // Cek jika departemen masih punya karyawan + // Cek sisa karyawan if ($department->employees()->count() > 0) { return back()->with('error', 'Gagal hapus! Departemen ini masih memiliki karyawan.'); } diff --git a/app/Http/Controllers/Admin/EmployeeController.php b/app/Http/Controllers/Admin/EmployeeController.php index 715caaa..a576cea 100644 --- a/app/Http/Controllers/Admin/EmployeeController.php +++ b/app/Http/Controllers/Admin/EmployeeController.php @@ -30,9 +30,9 @@ public function index(Request $request) } return Inertia::render('admin/employees/index', [ - 'employees' => $query->latest()->paginate(10)->withQueryString(), + 'employees' => $query->latest()->get(), 'departments' => Department::all(), - 'filters' => $request->only(['search', 'department_id']), + 'filters' => $request->only(['search', 'department_id']), ]); } @@ -49,26 +49,48 @@ public function store(Request $request) $validated = $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email|unique:users,email', - 'password' => 'required|string|min:8|confirmed', 'nip' => 'required|string|unique:employees,nip', 'gender' => 'required|in:L,P', 'place_of_birth' => 'nullable|string|max:255', 'birth_date' => 'required|date', 'address' => 'nullable|string', - 'phone_number' => 'nullable|string', + 'phone_number' => 'nullable|string|max:20', 'department_id' => 'required|exists:departments,id', 'position_id' => 'required|exists:positions,id', 'status' => 'required|in:PKWT,PKWTT,Magang', 'join_date' => 'required|date', + ], [ + 'name.required' => 'Nama lengkap wajib diisi.', + 'name.max' => 'Nama lengkap maksimal 255 karakter.', + 'email.required' => 'Email wajib diisi.', + 'email.email' => 'Format email tidak valid.', + 'email.unique' => 'Email sudah digunakan oleh karyawan lain.', + 'nip.required' => 'NIP wajib diisi.', + 'nip.unique' => 'NIP sudah terdaftar.', + 'gender.required' => 'Jenis kelamin wajib dipilih.', + 'gender.in' => 'Jenis kelamin hanya boleh Laki-laki atau Perempuan.', + 'place_of_birth.max' => 'Tempat lahir maksimal 255 karakter.', + 'birth_date.required' => 'Tanggal lahir wajib diisi.', + 'birth_date.date' => 'Format tanggal lahir tidak valid.', + 'phone_number.max' => 'Nomor telepon maksimal 20 karakter.', + 'department_id.required' => 'Departemen wajib dipilih.', + 'department_id.exists' => 'Departemen tidak ditemukan.', + 'position_id.required' => 'Jabatan wajib dipilih.', + 'position_id.exists' => 'Jabatan tidak ditemukan.', + 'status.required' => 'Status kepegawaian wajib dipilih.', + 'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.', + 'join_date.required' => 'Tanggal masuk wajib diisi.', + 'join_date.date' => 'Format tanggal masuk tidak valid.', ]); $user = User::create([ 'name' => $validated['name'], 'email' => $validated['email'], - 'password' => Hash::make($validated['password']), + 'password' => Hash::make('password'), + 'role' => 'employee', ]); - Employee::create([ + $employee = Employee::create([ 'user_id' => $user->id, 'nip' => $validated['nip'], 'name' => $validated['name'], @@ -83,6 +105,8 @@ public function store(Request $request) 'join_date' => $validated['join_date'], ]); + $user->update(['employee_id' => $employee->id]); + return redirect()->route('admin.employees.index') ->with('success', 'Data karyawan berhasil ditambahkan.'); } @@ -108,11 +132,33 @@ public function update(Request $request, Employee $employee) 'place_of_birth' => 'nullable|string|max:255', 'birth_date' => 'required|date', 'address' => 'nullable|string', - 'phone_number' => 'nullable|string', + 'phone_number' => 'nullable|string|max:20', 'department_id' => 'required|exists:departments,id', 'position_id' => 'required|exists:positions,id', 'status' => 'required|in:PKWT,PKWTT,Magang', 'join_date' => 'required|date', + ], [ + 'name.required' => 'Nama lengkap wajib diisi.', + 'name.max' => 'Nama lengkap maksimal 255 karakter.', + 'email.required' => 'Email wajib diisi.', + 'email.email' => 'Format email tidak valid.', + 'email.unique' => 'Email sudah digunakan oleh karyawan lain.', + 'nip.required' => 'NIP wajib diisi.', + 'nip.unique' => 'NIP sudah terdaftar.', + 'gender.required' => 'Jenis kelamin wajib dipilih.', + 'gender.in' => 'Jenis kelamin hanya boleh Laki-laki atau Perempuan.', + 'place_of_birth.max' => 'Tempat lahir maksimal 255 karakter.', + 'birth_date.required' => 'Tanggal lahir wajib diisi.', + 'birth_date.date' => 'Format tanggal lahir tidak valid.', + 'phone_number.max' => 'Nomor telepon maksimal 20 karakter.', + 'department_id.required' => 'Departemen wajib dipilih.', + 'department_id.exists' => 'Departemen tidak ditemukan.', + 'position_id.required' => 'Jabatan wajib dipilih.', + 'position_id.exists' => 'Jabatan tidak ditemukan.', + 'status.required' => 'Status kepegawaian wajib dipilih.', + 'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.', + 'join_date.required' => 'Tanggal masuk wajib diisi.', + 'join_date.date' => 'Format tanggal masuk tidak valid.', ]); $employee->user->update([ diff --git a/app/Http/Controllers/Admin/PayrollController.php b/app/Http/Controllers/Admin/PayrollController.php index 5a31694..49d4943 100644 --- a/app/Http/Controllers/Admin/PayrollController.php +++ b/app/Http/Controllers/Admin/PayrollController.php @@ -11,23 +11,37 @@ class PayrollController extends Controller { - public function index() + public function index(Request $request) { - // Eager loading untuk mencegah N+1 query - $payrolls = Payroll::with('employee') - ->latest() - ->get() - ->map(fn($p) => [ - 'id' => $p->id, - 'period' => $p->period, - 'net_salary' => $p->net_salary, - 'status' => $p->status, - 'employee_name' => $p->employee?->name ?? '-', - 'employee_nip' => $p->employee?->nip ?? '-', - ]); + $query = Payroll::with('employee'); + + if ($request->search) { + $query->whereHas('employee', function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('nip', 'like', '%' . $request->search . '%'); + }); + } + + if ($request->period) { + $query->where('period', $request->period); + } + + if ($request->status) { + $query->where('status', $request->status); + } + + $payrolls = $query->latest()->get()->map(fn($p) => [ + 'id' => $p->id, + 'period' => $p->period, + 'net_salary' => $p->net_salary, + 'status' => $p->status, + 'employee_name' => $p->employee?->name ?? '-', + 'employee_nip' => $p->employee?->nip ?? '-', + ]); return Inertia::render('admin/payrolls/index', [ 'payrolls' => $payrolls, + 'filters' => $request->only(['search', 'period', 'status']), ]); } @@ -70,11 +84,24 @@ public function store(Request $request) 'details.*.type' => 'required|in:bonus,deduction', 'details.*.amount' => 'required|integer|min:0', ], [ + 'employee_id.required' => 'Karyawan wajib dipilih.', + 'employee_id.exists' => 'Karyawan tidak ditemukan.', + 'period.required' => 'Periode gaji wajib diisi.', 'period.date_format' => 'Format periode harus YYYY-MM.', 'period.unique' => 'Payroll karyawan untuk periode ini sudah ada.', + 'basic_salary.required' => 'Gaji pokok wajib diisi.', + 'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.', + 'basic_salary.min' => 'Gaji pokok tidak boleh negatif.', + 'details.array' => 'Detail komponen harus berupa array.', + 'details.*.name.required' => 'Nama komponen wajib diisi.', + 'details.*.name.max' => 'Nama komponen maksimal 100 karakter.', + 'details.*.type.required' => 'Tipe komponen wajib dipilih.', + 'details.*.type.in' => 'Tipe komponen hanya boleh bonus atau potongan.', + 'details.*.amount.required' => 'Nominal komponen wajib diisi.', + 'details.*.amount.integer' => 'Nominal komponen harus berupa angka bulat.', + 'details.*.amount.min' => 'Nominal komponen tidak boleh negatif.', ]); - // net_salary = basic_salary + Ξ£bonus βˆ’ Ξ£potongan, minimal 0 $net = $validated['basic_salary']; foreach ($validated['details'] ?? [] as $item) { $net += $item['type'] === 'bonus' @@ -117,4 +144,82 @@ public function show(Payroll $payroll) ], ]); } + + public function edit(Payroll $payroll) + { + $payroll->load('employee.position', 'employee.department'); + + return Inertia::render('admin/payrolls/edit', [ + 'payroll' => [ + 'id' => $payroll->id, + 'period' => $payroll->period, + 'basic_salary' => $payroll->basic_salary, + 'details' => $payroll->details ?? [], + 'net_salary' => $payroll->net_salary, + 'status' => $payroll->status, + 'employee' => [ + 'id' => $payroll->employee?->id, + 'name' => $payroll->employee?->name, + 'nip' => $payroll->employee?->nip, + 'position' => $payroll->employee?->position?->name, + 'department' => $payroll->employee?->department?->name, + ], + ], + ]); + } + + public function update(Request $request, Payroll $payroll) + { + $validated = $request->validate([ + 'basic_salary' => 'required|integer|min:0', + 'details' => 'nullable|array', + 'details.*.name' => 'required|string|max:100', + 'details.*.type' => 'required|in:bonus,deduction', + 'details.*.amount' => 'required|integer|min:0', + ], [ + 'basic_salary.required' => 'Gaji pokok wajib diisi.', + 'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.', + 'basic_salary.min' => 'Gaji pokok tidak boleh negatif.', + 'details.*.name.required' => 'Nama komponen wajib diisi.', + 'details.*.type.required' => 'Tipe komponen wajib dipilih.', + 'details.*.type.in' => 'Tipe hanya boleh bonus atau potongan.', + 'details.*.amount.required' => 'Nominal wajib diisi.', + 'details.*.amount.min' => 'Nominal tidak boleh negatif.', + ]); + + $net = $validated['basic_salary']; + foreach ($validated['details'] ?? [] as $item) { + $net += $item['type'] === 'bonus' + ? $item['amount'] + : -$item['amount']; + } + + $payroll->update([ + 'basic_salary' => $validated['basic_salary'], + 'details' => $validated['details'] ?? [], + 'net_salary' => max(0, $net), + ]); + + return redirect()->route('admin.payrolls.show', $payroll) + ->with('success', 'Data payroll berhasil diperbarui.'); + } + + /** + * Update hanya field status payroll (menunggu / telah_dikirim). + */ + public function updateStatus(Request $request, Payroll $payroll) + { + $validated = $request->validate([ + 'status' => ['required', Rule::in(['pending', 'paid'])], + ], [ + 'status.required' => 'Status wajib dipilih.', + 'status.in' => 'Status hanya boleh pending atau paid.', + ]); + + $payroll->update(['status' => $validated['status']]); + + $label = $validated['status'] === 'paid' ? 'Telah Dikirim' : 'Menunggu'; + + return back()->with('success', "Status payroll diubah menjadi '{$label}'."); + } } diff --git a/app/Http/Controllers/Admin/PositionController.php b/app/Http/Controllers/Admin/PositionController.php index 1b22ab7..e706b54 100644 --- a/app/Http/Controllers/Admin/PositionController.php +++ b/app/Http/Controllers/Admin/PositionController.php @@ -9,10 +9,15 @@ class PositionController extends Controller { - public function index() + public function index(Request $request) { + $query = Position::withCount('employees'); + if ($request->search) { + $query->where('name', 'like', '%' . $request->search . '%'); + } return Inertia::render('admin/positions/index', [ - 'positions' => Position::withCount('employees')->latest()->get(), + 'positions' => $query->latest()->get(), + 'filters' => $request->only(['search']), ]); } @@ -26,6 +31,12 @@ public function store(Request $request) $validated = $request->validate([ 'name' => 'required|string|max:255|unique:positions,name', 'basic_salary' => 'nullable|integer|min:0', + ], [ + 'name.required' => 'Nama jabatan wajib diisi.', + 'name.max' => 'Nama jabatan maksimal 255 karakter.', + 'name.unique' => 'Nama jabatan sudah terdaftar.', + 'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.', + 'basic_salary.min' => 'Gaji pokok tidak boleh negatif.', ]); Position::create($validated); @@ -46,6 +57,12 @@ public function update(Request $request, Position $position) $validated = $request->validate([ 'name' => 'required|string|max:255|unique:positions,name,' . $position->id, 'basic_salary' => 'nullable|integer|min:0', + ], [ + 'name.required' => 'Nama jabatan wajib diisi.', + 'name.max' => 'Nama jabatan maksimal 255 karakter.', + 'name.unique' => 'Nama jabatan sudah terdaftar.', + 'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.', + 'basic_salary.min' => 'Gaji pokok tidak boleh negatif.', ]); $position->update($validated); diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 2b5295c..3cca1d3 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -12,18 +12,27 @@ class UserController extends Controller { - public function index() + public function index(Request $request) { + $query = User::select('id', 'name', 'email', 'role', 'created_at'); + if ($request->search) { + $query->where(function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('email', 'like', '%' . $request->search . '%'); + }); + } + if ($request->role) { + $query->where('role', $request->role); + } return Inertia::render('admin/users/index', [ - 'users' => User::select('id', 'name', 'email', 'role', 'created_at') - ->latest() - ->get(), + 'users' => $query->latest()->get(), + 'filters' => $request->only(['search', 'role']), ]); } public function create() { - // whereDoesntHave mencegah 1 employee memiliki lebih dari 1 akun + // Filter employee tanpa akun $employees = Employee::whereDoesntHave('user') ->orderBy('name') ->get() @@ -48,7 +57,6 @@ public function store(Request $request) 'unique:users,employee_id', ], 'email' => 'required|email|max:255|unique:users,email', - 'password' => 'required|string|min:8|confirmed', 'role' => ['required', Rule::in(['admin', 'employee'])], ], [ 'employee_id.required' => 'Karyawan wajib dipilih.', @@ -57,23 +65,22 @@ public function store(Request $request) 'email.required' => 'Email wajib diisi.', 'email.email' => 'Format email tidak valid.', 'email.unique' => 'Email sudah terdaftar.', - 'password.required' => 'Password wajib diisi.', - 'password.min' => 'Password minimal 8 karakter.', - 'password.confirmed' => 'Konfirmasi password tidak cocok.', 'role.required' => 'Role wajib dipilih.', 'role.in' => 'Role hanya boleh admin atau employee.', ]); $employee = Employee::findOrFail($validated['employee_id']); - User::create([ + $user = User::create([ 'name' => $employee->name, 'email' => $validated['email'], - 'password' => Hash::make($validated['password']), + 'password' => Hash::make('password'), 'role' => $validated['role'], 'employee_id' => $employee->id, ]); + $employee->update(['user_id' => $user->id]); + return redirect()->route('admin.users.index') ->with('success', "Akun untuk {$employee->name} berhasil dibuat."); } @@ -82,6 +89,9 @@ public function update(Request $request, User $user) { $validated = $request->validate([ 'role' => ['required', Rule::in(['admin', 'employee'])], + ], [ + 'role.required' => 'Role wajib dipilih.', + 'role.in' => 'Role hanya boleh admin atau employee.', ]); $user->update($validated); diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..3aa2580 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -4,5 +4,4 @@ abstract class Controller { - // } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 2611e31..57cbfbd 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -4,8 +4,10 @@ use App\Models\Department; use App\Models\Employee; +use App\Models\Payroll; use App\Models\Position; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Inertia\Inertia; class DashboardController extends Controller @@ -16,26 +18,31 @@ public function index() return redirect()->route('employee.index'); } + $currentPeriod = Carbon::now()->format('Y-m'); + $stats = [ - 'total_employees' => Employee::count(), + 'total_employees' => Employee::count(), 'total_departments' => Department::count(), - 'total_positions' => Position::count(), + 'total_positions' => Position::count(), + 'payroll_realisasi' => Payroll::where('period', $currentPeriod) + ->where('status', 'paid') + ->sum('net_salary'), ]; $genderData = Employee::selectRaw('gender, count(*) as total') ->groupBy('gender') ->get() ->map(fn($item) => [ - 'name' => $item->gender == 'L' ? 'Laki-laki' : 'Perempuan', + 'name' => $item->gender == 'L' ? 'Laki-laki' : 'Perempuan', 'value' => $item->total, - 'fill' => $item->gender == 'L' ? '#3b82f6' : '#ec4899', + 'fill' => $item->gender == 'L' ? '#3b82f6' : '#ec4899', ]); $deptData = Department::withCount('employees') ->having('employees_count', '>', 0) ->get() ->map(fn($item) => [ - 'name' => $item->name, + 'name' => $item->name, 'employees' => $item->employees_count, ]); @@ -45,10 +52,11 @@ public function index() ->get(); return Inertia::render('dashboard', [ - 'stats' => $stats, - 'genderData' => $genderData, - 'deptData' => $deptData, + 'stats' => $stats, + 'genderData' => $genderData, + 'deptData' => $deptData, 'latestEmployees' => $latestEmployees, + 'currentPeriod' => $currentPeriod, ]); } } \ No newline at end of file diff --git a/app/Http/Controllers/Employee/EmployeeAttendanceController.php b/app/Http/Controllers/Employee/EmployeeAttendanceController.php index 5d6d147..aab4adc 100644 --- a/app/Http/Controllers/Employee/EmployeeAttendanceController.php +++ b/app/Http/Controllers/Employee/EmployeeAttendanceController.php @@ -44,6 +44,11 @@ public function clockIn(Request $request) $request->validate([ 'latitude_in' => 'required|numeric', 'longitude_in' => 'required|numeric', + ], [ + 'latitude_in.required' => 'Lokasi latitude wajib diisi.', + 'latitude_in.numeric' => 'Lokasi latitude harus berupa angka.', + 'longitude_in.required' => 'Lokasi longitude wajib diisi.', + 'longitude_in.numeric' => 'Lokasi longitude harus berupa angka.', ]); $employeeId = $employee->id; @@ -79,6 +84,11 @@ public function clockOut(Request $request) $request->validate([ 'latitude_out' => 'required|numeric', 'longitude_out' => 'required|numeric', + ], [ + 'latitude_out.required' => 'Lokasi latitude wajib diisi.', + 'latitude_out.numeric' => 'Lokasi latitude harus berupa angka.', + 'longitude_out.required' => 'Lokasi longitude wajib diisi.', + 'longitude_out.numeric' => 'Lokasi longitude harus berupa angka.', ]); $employeeId = $employee->id; @@ -110,7 +120,13 @@ public function submitLeave(Request $request) $request->validate([ 'date' => 'required|date', - 'notes' => 'required|string', + 'notes' => 'required|string|min:5', + ], [ + 'date.required' => 'Tanggal izin wajib diisi.', + 'date.date' => 'Format tanggal tidak valid.', + 'notes.required' => 'Keterangan izin wajib diisi.', + 'notes.string' => 'Keterangan izin harus berupa teks.', + 'notes.min' => 'Keterangan izin minimal 5 karakter.', ]); Attendance::create([ @@ -133,7 +149,15 @@ public function submitDispensation(Request $request) $request->validate([ 'latitude_in' => 'required|numeric', 'longitude_in' => 'required|numeric', - 'notes' => 'required|string', + 'notes' => 'required|string|min:5', + ], [ + 'latitude_in.required' => 'Lokasi latitude wajib diisi.', + 'latitude_in.numeric' => 'Lokasi latitude harus berupa angka.', + 'longitude_in.required' => 'Lokasi longitude wajib diisi.', + 'longitude_in.numeric' => 'Lokasi longitude harus berupa angka.', + 'notes.required' => 'Keterangan dispensasi wajib diisi.', + 'notes.string' => 'Keterangan dispensasi harus berupa teks.', + 'notes.min' => 'Keterangan dispensasi minimal 5 karakter.', ]); $now = Carbon::now(); diff --git a/app/Http/Controllers/Employee/EmployeeDashboardController.php b/app/Http/Controllers/Employee/EmployeeDashboardController.php new file mode 100644 index 0000000..15be824 --- /dev/null +++ b/app/Http/Controllers/Employee/EmployeeDashboardController.php @@ -0,0 +1,54 @@ +user()->employee; + if (!$employee) { + return Inertia::render('employee/index', [ + 'stats' => null, + ]); + } + + $currentMonth = Carbon::now()->month; + $currentYear = Carbon::now()->year; + + $attendances = Attendance::where('employee_id', $employee->id) + ->whereMonth('date', $currentMonth) + ->whereYear('date', $currentYear) + ->get(); + + $presentCount = $attendances->where('status', 'present')->count(); + $leaveCount = $attendances->where('status', 'leave')->count(); + $dispensationCount = $attendances->where('status', 'dispensation')->count(); + + // Batas telat 08:00 + $onTimeCount = $attendances->where('status', 'present')->filter(function ($att) { + return $att->check_in && $att->check_in <= '08:00:00'; + })->count(); + + $lateCount = $attendances->where('status', 'present')->filter(function ($att) { + return $att->check_in && $att->check_in > '08:00:00'; + })->count(); + + return Inertia::render('employee/index', [ + 'stats' => [ + 'present' => $presentCount, + 'leave' => $leaveCount, + 'dispensation' => $dispensationCount, + 'on_time' => $onTimeCount, + 'late' => $lateCount, + ], + 'employee' => $employee, + ]); + } +} diff --git a/database/migrations/2026_02_14_120000_create_departments_table.php b/database/migrations/2026_02_14_120000_create_departments_table.php index 66895e3..c0a4cb2 100644 --- a/database/migrations/2026_02_14_120000_create_departments_table.php +++ b/database/migrations/2026_02_14_120000_create_departments_table.php @@ -9,7 +9,7 @@ */ public function up(): void { - // Duplicate migration kept as a no-op so existing schema is not recreated. + // No-op } /** @@ -17,6 +17,6 @@ public function up(): void */ public function down(): void { - // Intentionally no-op. + // No-op } }; diff --git a/database/migrations/2026_02_14_120500_create_positions_table.php b/database/migrations/2026_02_14_120500_create_positions_table.php index 101480b..a28f139 100644 --- a/database/migrations/2026_02_14_120500_create_positions_table.php +++ b/database/migrations/2026_02_14_120500_create_positions_table.php @@ -8,7 +8,7 @@ */ public function up(): void { - // Duplicate migration kept as a no-op so existing schema is not recreated. + // No-op } /** @@ -16,6 +16,6 @@ public function up(): void */ public function down(): void { - // Intentionally no-op. + // No-op } }; diff --git a/database/migrations/2026_02_14_999999_create_employees_table.php b/database/migrations/2026_02_14_999999_create_employees_table.php index 827521c..d0189de 100644 --- a/database/migrations/2026_02_14_999999_create_employees_table.php +++ b/database/migrations/2026_02_14_999999_create_employees_table.php @@ -15,7 +15,7 @@ public function up() $table->id(); $table->foreignId('user_id')->constrained()->onDelete('cascade'); - // Data Pribadi + $table->string('nip')->unique(); $table->string('name'); $table->enum('gender', ['L', 'P']); @@ -24,11 +24,11 @@ public function up() $table->text('address')->nullable(); $table->string('phone_number')->nullable(); - // Data Pekerjaan + $table->foreignId('department_id')->constrained()->onDelete('restrict'); $table->foreignId('position_id')->constrained()->onDelete('restrict'); - // Status & Tanggal + $table->enum('status', ['PKWT', 'PKWTT', 'Magang']); $table->date('join_date'); diff --git a/database/migrations/2026_04_20_110000_create_attendances_table.php b/database/migrations/2026_04_20_110000_create_attendances_table.php index 8cfba4c..f7e8547 100644 --- a/database/migrations/2026_04_20_110000_create_attendances_table.php +++ b/database/migrations/2026_04_20_110000_create_attendances_table.php @@ -1,30 +1,16 @@ id(); - $table->foreignId('employee_id')->constrained()->onDelete('cascade'); - $table->date('date'); - $table->enum('shift', ['Pagi', 'Siang', 'Malam']); - $table->dateTime('check_in')->nullable(); - $table->dateTime('check_out')->nullable(); - $table->enum('status', ['hadir', 'izin', 'sakit', 'alpha'])->default('hadir'); - $table->string('notes')->nullable(); - $table->timestamps(); - - $table->unique(['employee_id', 'date'], 'attendances_employee_date_unique'); - }); + // No-op } public function down(): void { - Schema::dropIfExists('attendances'); + // No-op } }; diff --git a/database/seeders/EmployeeSeeder.php b/database/seeders/EmployeeSeeder.php index 99a9141..187ae86 100644 --- a/database/seeders/EmployeeSeeder.php +++ b/database/seeders/EmployeeSeeder.php @@ -61,7 +61,8 @@ public function run(): void foreach ($employees as $data) { $user = User::create($data['user']); - Employee::create(array_merge($data['employee'], ['user_id' => $user->id])); + $employee = Employee::create(array_merge($data['employee'], ['user_id' => $user->id])); + $user->update(['employee_id' => $employee->id]); } $this->command->info('EmployeeSeeder: ' . count($employees) . ' karyawan berhasil dibuat.'); diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 7b5a49a..5f47dd9 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -6,66 +6,29 @@ use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; +/** + * UserSeeder: Hanya membuat akun-akun Admin. + * Akun Employee dibuat oleh EmployeeSeeder (bersama data karyawan). + */ class UserSeeder extends Seeder { public function run(): void { // Akun Admin Utama User::create([ - 'name' => 'admin', - 'email' => 'admin@gmail.com', + 'name' => 'Admin HRIS', + 'email' => 'admin@hris.com', 'password' => Hash::make('password'), - 'role' => 'admin', + 'role' => 'admin', ]); User::create([ - 'name' => 'Jono Joni', - 'email' => 'jono@gmail.com', + 'name' => 'Siti Nurhaliza', + 'email' => 'siti.admin@hris.com', 'password' => Hash::make('password'), - 'role' => 'employee', - ]); - User::create([ - 'name' => 'Siti Nurhaliza', - 'email' => 'siti.admin@hris.com', - 'password' => Hash::make('password'), - 'role' => 'admin', - ]); - - // --- Akun Karyawan (Employee) --- - User::create([ - 'name' => 'Budi Santoso', - 'email' => 'budi.santoso@hris.com', - 'password' => Hash::make('password'), - 'role' => 'employee', - ]); - - User::create([ - 'name' => 'Dewi Rahayu', - 'email' => 'dewi.rahayu@hris.com', - 'password' => Hash::make('password'), - 'role' => 'employee', - ]); - - User::create([ - 'name' => 'Rizky Firmansyah', - 'email' => 'rizky.firmansyah@hris.com', - 'password' => Hash::make('password'), - 'role' => 'employee', - ]); - - User::create([ - 'name' => 'Anisa Putri', - 'email' => 'anisa.putri@hris.com', - 'password' => Hash::make('password'), - 'role' => 'employee', - ]); - - User::create([ - 'name' => 'Hendra Kurniawan', - 'email' => 'hendra.kurniawan@hris.com', - 'password' => Hash::make('password'), - 'role' => 'employee', + 'role' => 'admin', ]); + $this->command->info('UserSeeder: Akun admin berhasil dibuat.'); } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 43719e9..51b464c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "HR_Management__App", + "name": "HRIS_App", "lockfileVersion": 3, "requires": true, "packages": { @@ -21,6 +21,7 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.1.11", + "@types/leaflet": "^1.9.21", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.0", @@ -30,17 +31,21 @@ "globals": "^15.14.0", "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", + "leaflet": "^1.9.4", "lucide-react": "^0.475.0", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "recharts": "^3.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", "use-debounce": "^10.1.0", - "vite": "^7.0.4" + "vite": "^7.0.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.19.0", @@ -94,7 +99,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -4674,6 +4678,17 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@react-stately/flags": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", @@ -5503,6 +5518,12 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5517,6 +5538,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", @@ -5538,7 +5568,6 @@ "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5548,7 +5577,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5558,7 +5586,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5614,7 +5641,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -6134,7 +6160,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6152,6 +6177,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -6471,7 +6505,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6564,6 +6597,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6627,6 +6673,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6694,6 +6749,18 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7284,7 +7351,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7471,7 +7537,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7822,6 +7887,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8803,6 +8877,12 @@ "vite": "^7.0.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9583,7 +9663,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9935,7 +10014,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9945,7 +10023,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9957,15 +10034,27 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -10096,8 +10185,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -10513,6 +10601,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -10522,6 +10620,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -10937,7 +11047,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11003,7 +11112,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -11163,7 +11271,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11360,6 +11467,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11387,6 +11512,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -11448,7 +11594,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 875f65f..84d7c2c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.1.11", + "@types/leaflet": "^1.9.21", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.0", @@ -52,17 +53,21 @@ "globals": "^15.14.0", "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", + "leaflet": "^1.9.4", "lucide-react": "^0.475.0", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "recharts": "^3.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", "use-debounce": "^10.1.0", - "vite": "^7.0.4" + "vite": "^7.0.4", + "xlsx": "^0.18.5" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", diff --git a/public/assets/logo-clear.png b/public/assets/logo-clear.png new file mode 100644 index 0000000..68ae327 Binary files /dev/null and b/public/assets/logo-clear.png differ diff --git a/public/assets/logo.jpg b/public/assets/logo.jpg new file mode 100644 index 0000000..cdc85a2 Binary files /dev/null and b/public/assets/logo.jpg differ diff --git a/resources/js/components/AttendanceMapModal.tsx b/resources/js/components/AttendanceMapModal.tsx new file mode 100644 index 0000000..c351c7c --- /dev/null +++ b/resources/js/components/AttendanceMapModal.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { MapPin } from 'lucide-react'; + +// Perbaikan issue icon marker Leaflet di React +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; + +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconUrl: markerIcon, + iconRetinaUrl: markerIcon2x, + shadowUrl: markerShadow, +}); + +interface AttendanceMapModalProps { + latitudeIn?: string | null; + longitudeIn?: string | null; + latitudeOut?: string | null; + longitudeOut?: string | null; + employeeName: string; + date: string; +} + +export function AttendanceMapModal({ latitudeIn, longitudeIn, latitudeOut, longitudeOut, employeeName, date }: AttendanceMapModalProps) { + const hasIn = latitudeIn && longitudeIn; + const hasOut = latitudeOut && longitudeOut; + const hasData = hasIn || hasOut; + + if (!hasData) { + return ( + + Tidak ada data lokasi + + ); + } + + const posIn: [number, number] | null = hasIn ? [parseFloat(latitudeIn as string), parseFloat(longitudeIn as string)] : null; + const posOut: [number, number] | null = hasOut ? [parseFloat(latitudeOut as string), parseFloat(longitudeOut as string)] : null; + + // Default center ke check in, atau check out + const center = posIn || posOut || [-6.200000, 106.816666]; + + return ( + + + + + + + Detail Lokasi Absensi +

+ {employeeName} β€” {date} +

+
+
+ + + {posIn && ( + + +
πŸ“ Lokasi Clock In
+
+ Lat: {posIn[0]}
+ Lng: {posIn[1]} +
+
+
+ )} + {posOut && ( + + +
πŸ“ Lokasi Clock Out
+
+ Lat: {posOut[0]}
+ Lng: {posOut[1]} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 4f13cc1..14f015a 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -45,7 +45,7 @@ export function AppSidebar() { { title: 'Manajemen Absensi', href: '/admin/attendance', - icon: CalendarCheck2, + icon: CalendarClock, }, { title: 'Manajemen Gaji', diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx index a6edb62..15ba427 100644 --- a/resources/js/layouts/app/app-sidebar-layout.tsx +++ b/resources/js/layouts/app/app-sidebar-layout.tsx @@ -3,6 +3,19 @@ import { AppShell } from '@/components/app-shell'; import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebarHeader } from '@/components/app-sidebar-header'; import type { AppLayoutProps } from '@/types'; +import { usePage } from '@inertiajs/react'; +import { useEffect } from 'react'; +import { Toaster, toast } from 'sonner'; + +/** Auto-show Laravel flash messages as Sonner toasts on every page visit */ +function FlashToast() { + const { flash } = usePage<{ flash?: { success?: string; error?: string } }>().props; + useEffect(() => { + if (flash?.success) toast.success(flash.success); + if (flash?.error) toast.error(flash.error); + }, [flash]); + return null; +} export default function AppSidebarLayout({ children, @@ -13,8 +26,10 @@ export default function AppSidebarLayout({ + {children} + ); } diff --git a/resources/js/layouts/employee-layout.tsx b/resources/js/layouts/employee-layout.tsx new file mode 100644 index 0000000..a85eae4 --- /dev/null +++ b/resources/js/layouts/employee-layout.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Toaster } from 'sonner'; +import { Head, Link, usePage } from '@inertiajs/react'; +import { LogOut, Home, CalendarClock, User, Menu } from 'lucide-react'; + +interface EmployeeLayoutProps { + children: React.ReactNode; + title?: string; +} + +export default function EmployeeLayout({ children, title }: EmployeeLayoutProps) { + const user = (usePage().props.auth as any).user; + + return ( +
+ {title && } + + + + +
+ Logo { e.currentTarget.style.display='none'; }} /> +
HRIS Portal
+
+
+ +
+ {children} +
+ + +
+ ); +} diff --git a/resources/js/pages/admin/attendance/create.tsx b/resources/js/pages/admin/attendance/create.tsx index ef0bb73..55eb125 100644 --- a/resources/js/pages/admin/attendance/create.tsx +++ b/resources/js/pages/admin/attendance/create.tsx @@ -26,7 +26,7 @@ interface PageProps { } export default function Create({ employees }: PageProps) { - const { data, setData, post, processing, errors } = useForm({ + const { data, setData, post, processing, errors, transform } = useForm({ employee_id: '', date: '', shift: '', @@ -41,14 +41,14 @@ export default function Create({ employees }: PageProps) { const submit = (e: React.FormEvent) => { e.preventDefault(); - post('/admin/attendance', { - transform: (formData) => ({ - ...formData, - check_in: formData.check_in || null, - check_out: formData.check_out || null, - notes: formData.notes || null, - }), - }); + transform((formData) => ({ + ...formData, + check_in: formData.check_in || null, + check_out: formData.check_out || null, + notes: formData.notes || null, + })); + + post('/admin/attendance'); }; return ( diff --git a/resources/js/pages/admin/attendances/index.tsx b/resources/js/pages/admin/attendances/index.tsx index 8fb442d..65e6234 100644 --- a/resources/js/pages/admin/attendances/index.tsx +++ b/resources/js/pages/admin/attendances/index.tsx @@ -1,9 +1,16 @@ -import { Head, usePage } from '@inertiajs/react'; -import React from 'react'; +import { Head, router } from '@inertiajs/react'; +import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import AppLayout from '@/layouts/app-layout'; +import { Search } from 'lucide-react'; +import { useDebounce } from 'use-debounce'; +import * as XLSX from 'xlsx'; +import { AttendanceMapModal } from '@/components/AttendanceMapModal'; interface Attendance { id: number; @@ -15,14 +22,51 @@ interface Attendance { status: 'present' | 'leave' | 'dispensation'; notes: string | null; total_hours: string | null; + latitude_in?: string | null; + longitude_in?: string | null; + latitude_out?: string | null; + longitude_out?: string | null; } interface PageProps { attendances: Attendance[]; + filters: { search?: string; date?: string; status?: string }; [key: string]: unknown; } -export default function Index({ attendances }: PageProps) { +export default function Index({ attendances, filters }: PageProps) { + const [search, setSearch] = useState(filters.search || ''); + const [date, setDate] = useState(filters.date || ''); + const [status, setStatus] = useState(filters.status || 'all'); + const [debouncedSearch] = useDebounce(search, 500); + + useEffect(() => { + if (debouncedSearch !== filters.search || date !== filters.date || (status !== 'all' && status !== filters.status)) { + router.get( + '/admin/attendances', + { search: debouncedSearch, date, status: status === 'all' ? '' : status }, + { preserveState: true, replace: true } + ); + } + }, [debouncedSearch, date, status]); + + const exportExcel = () => { + const rows = attendances.map((a, i) => ({ + No: i + 1, + Nama: a.employee_name, + NIK: a.employee_nik, + Tanggal: a.date, + 'Jam Masuk': a.check_in || '-', + 'Jam Keluar': a.check_out || '-', + Status: a.status === 'present' ? 'Hadir' : a.status === 'leave' ? 'Izin/Cuti' : 'Dispensasi', + 'Total Jam': a.total_hours ? `${a.total_hours} Jam` : '-', + Catatan: a.notes || '-', + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Absensi'); + XLSX.writeFile(wb, `Data_Absensi_${new Date().toISOString().slice(0, 10)}.xlsx`); + }; const formatStatus = (status: string) => { switch (status) { case 'present': return Hadir; @@ -43,10 +87,50 @@ export default function Index({ attendances }: PageProps) { - + Daftar Absensi + + +
+
+ +
+ + setSearch(e.target.value)} + /> +
+
+
+ + setDate(e.target.value)} + /> +
+
+ + +
+
+ +
+
+
@@ -57,6 +141,7 @@ export default function Index({ attendances }: PageProps) { TanggalJam MasukJam Keluar + Lokasi PetaKeteranganTotal Jam Kerja @@ -70,6 +155,16 @@ export default function Index({ attendances }: PageProps) { {attendance.date}{attendance.check_in || '-'}{attendance.check_out || '-'} + + +
{formatStatus(attendance.status)} @@ -85,7 +180,7 @@ export default function Index({ attendances }: PageProps) { )) ) : ( - + Belum ada data absensi. diff --git a/resources/js/pages/admin/departments/create.tsx b/resources/js/pages/admin/departments/create.tsx index 191af23..c6dea04 100644 --- a/resources/js/pages/admin/departments/create.tsx +++ b/resources/js/pages/admin/departments/create.tsx @@ -68,7 +68,7 @@ export default function Create() { Batal
diff --git a/resources/js/pages/admin/departments/edit.tsx b/resources/js/pages/admin/departments/edit.tsx index db77149..a43157e 100644 --- a/resources/js/pages/admin/departments/edit.tsx +++ b/resources/js/pages/admin/departments/edit.tsx @@ -75,7 +75,7 @@ export default function Edit({ department }: PageProps) { Batal diff --git a/resources/js/pages/admin/departments/index.tsx b/resources/js/pages/admin/departments/index.tsx index ff15689..1395f61 100644 --- a/resources/js/pages/admin/departments/index.tsx +++ b/resources/js/pages/admin/departments/index.tsx @@ -9,8 +9,14 @@ import { TooltipContent, TooltipProvider, TooltipTrigger, -} from "@/components/ui/tooltip"; // Import Tooltip +} from "@/components/ui/tooltip"; +import { Input } from '@/components/ui/input'; import AppLayout from '@/layouts/app-layout'; +import { Search } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { useDebounce } from 'use-debounce'; +import { router } from '@inertiajs/react'; +import * as XLSX from 'xlsx'; interface Department { id: number; @@ -21,18 +27,36 @@ interface Department { interface PageProps { departments: Department[]; + filters: { search?: string }; [key: string]: unknown; } -interface SharedData { - flash: { - success?: string; - error?: string; - }; -} +export default function Index({ departments, filters }: PageProps) { + const [search, setSearch] = useState(filters.search || ''); + const [debouncedSearch] = useDebounce(search, 500); -export default function Index({ departments }: PageProps) { - const { flash } = usePage().props as SharedData; + useEffect(() => { + if (debouncedSearch !== filters.search) { + router.get( + '/admin/departments', + { search: debouncedSearch }, + { preserveState: true, replace: true } + ); + } + }, [debouncedSearch]); + + const exportExcel = () => { + const rows = departments.map((d, i) => ({ + No: i + 1, + 'Nama Departemen': d.name, + Deskripsi: d.description || '-', + 'Total Karyawan': d.employees_count, + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Departemen'); + XLSX.writeFile(wb, `Data_Departemen_${new Date().toISOString().slice(0, 10)}.xlsx`); + }; return ( @@ -46,26 +70,30 @@ export default function Index({ departments }: PageProps) {

Kelola struktur organisasi dan unit kerja.

- {/* Flash Messages */} - {flash?.success && ( -
- {flash.success} -
- )} - {flash?.error && ( -
- {flash.error} -
- )} - Daftar Departemen - +
+ + +
+ +
+
+ + setSearch(e.target.value)} + /> +
+
+
diff --git a/resources/js/pages/admin/employees/create.tsx b/resources/js/pages/admin/employees/create.tsx index 541804d..9ed8827 100644 --- a/resources/js/pages/admin/employees/create.tsx +++ b/resources/js/pages/admin/employees/create.tsx @@ -18,8 +18,6 @@ export default function Create({ departments, positions }: PageProps) { const { data, setData, post, processing, errors } = useForm({ name: '', email: '', - password: '', - password_confirmation: '', nip: '', gender: '', birth_date: '', @@ -85,31 +83,13 @@ export default function Create({ departments, positions }: PageProps) { {errors.email &&
{errors.email}
} -
- - setData('password', e.target.value)} - /> - {errors.password &&
{errors.password}
} -
- -
- - setData('password_confirmation', e.target.value)} - /> -
-
setData('phone_number', e.target.value)} /> + {errors.phone_number &&
{errors.phone_number}
}
@@ -122,6 +102,7 @@ export default function Create({ departments, positions }: PageProps) { value={data.place_of_birth} onChange={e => setData('place_of_birth', e.target.value)} /> + {errors.place_of_birth &&
{errors.place_of_birth}
}
@@ -152,6 +133,7 @@ export default function Create({ departments, positions }: PageProps) { value={data.address} onChange={e => setData('address', e.target.value)} /> + {errors.address &&
{errors.address}
}
@@ -244,7 +226,7 @@ export default function Create({ departments, positions }: PageProps) { Batal diff --git a/resources/js/pages/admin/employees/edit.tsx b/resources/js/pages/admin/employees/edit.tsx index 86a1536..e66d635 100644 --- a/resources/js/pages/admin/employees/edit.tsx +++ b/resources/js/pages/admin/employees/edit.tsx @@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Separator } from '@/components/ui/separator'; import AppLayout from '@/layouts/app-layout'; -// Definisi Tipe Data Employee yang diterima dari Controller + interface Employee { id: number; nip: string; @@ -35,7 +35,7 @@ interface PageProps { } export default function Edit({ employee, departments, positions }: PageProps) { - // Inisialisasi Form dengan Data Lama (Pre-filled) + // Inisialisasi State const { data, setData, put, processing, errors } = useForm({ name: employee.user.name, email: employee.user.email, @@ -109,6 +109,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { value={data.phone_number} onChange={e => setData('phone_number', e.target.value)} /> + {errors.phone_number &&
{errors.phone_number}
} @@ -121,6 +122,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { value={data.place_of_birth} onChange={e => setData('place_of_birth', e.target.value)} /> + {errors.place_of_birth &&
{errors.place_of_birth}
}
@@ -151,6 +153,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { value={data.address} onChange={e => setData('address', e.target.value)} /> + {errors.address &&
{errors.address}
}
@@ -187,6 +190,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { ))} + {errors.department_id &&
{errors.department_id}
} @@ -202,6 +206,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { ))} + {errors.position_id &&
{errors.position_id}
}
@@ -215,6 +220,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { Magang + {errors.status &&
{errors.status}
}
@@ -224,6 +230,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { value={data.join_date} onChange={e => setData('join_date', e.target.value)} /> + {errors.join_date &&
{errors.join_date}
}
@@ -238,7 +245,7 @@ export default function Edit({ employee, departments, positions }: PageProps) { Batal diff --git a/resources/js/pages/admin/employees/index.tsx b/resources/js/pages/admin/employees/index.tsx index 45d79c3..752719f 100644 --- a/resources/js/pages/admin/employees/index.tsx +++ b/resources/js/pages/admin/employees/index.tsx @@ -36,10 +36,7 @@ interface Employee { } interface PageProps { - employees: { - data: Employee[]; - links: any[]; - }; + employees: Employee[]; departments: { id: number; name: string }[]; filters: { search?: string; @@ -48,15 +45,9 @@ interface PageProps { [key: string]: unknown; } -interface SharedData { - flash: { - success?: string; - error?: string; - }; -} +import * as XLSX from 'xlsx'; export default function Index({ employees, departments, filters }: PageProps) { - const { flash } = usePage().props as SharedData; const [search, setSearch] = useState(filters.search || ''); const [departmentId, setDepartmentId] = useState(filters.department_id || 'all'); const [debouncedSearch] = useDebounce(search, 500); @@ -74,6 +65,23 @@ export default function Index({ employees, departments, filters }: PageProps) { } }, [debouncedSearch, departmentId]); + const exportExcel = () => { + const rows = employees.map((p, i) => ({ + No: i + 1, + Nama: p.user?.name || '-', + Email: p.user?.email || '-', + NIP: p.nip, + Departemen: p.department?.name || '-', + Jabatan: p.position?.name || '-', + Status: p.status, + Bergabung: new Date(p.join_date).toLocaleDateString('id-ID'), + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Data Karyawan'); + XLSX.writeFile(wb, `Data_Karyawan_${new Date().toISOString().slice(0, 10)}.xlsx`); + }; + return ( @@ -85,19 +93,15 @@ export default function Index({ employees, departments, filters }: PageProps) {

Kelola data seluruh karyawan perusahaan.

- {/* Flash Messages */} - {flash?.success && ( -
- {flash.success} -
- )} - Daftar Karyawan - +
+ + +
@@ -142,8 +146,8 @@ export default function Index({ employees, departments, filters }: PageProps) {
- {employees.data.length > 0 ? ( - employees.data.map((employee) => ( + {employees.length > 0 ? ( + employees.map((employee) => (
{employee.user?.name}
diff --git a/resources/js/pages/admin/payrolls/create.tsx b/resources/js/pages/admin/payrolls/create.tsx index 2f1c370..9c43fbf 100644 --- a/resources/js/pages/admin/payrolls/create.tsx +++ b/resources/js/pages/admin/payrolls/create.tsx @@ -15,7 +15,7 @@ import { Separator } from '@/components/ui/separator'; import AppLayout from '@/layouts/app-layout'; import { PlusCircle, Trash2 } from 'lucide-react'; -// ─── Types ─────────────────────────────────────────────────────────────────── + interface Position { id: number; @@ -45,7 +45,7 @@ interface PageProps { employees: Employee[]; } -// ─── Helpers ───────────────────────────────────────────────────────────────── + function formatRupiah(value: number): string { return new Intl.NumberFormat('id-ID', { @@ -55,7 +55,7 @@ function formatRupiah(value: number): string { }).format(value); } -// ─── Component ─────────────────────────────────────────────────────────────── + export default function Create({ employees }: PageProps) { const { data, setData, post, processing, errors } = useForm<{ @@ -72,14 +72,14 @@ export default function Create({ employees }: PageProps) { const [selectedEmployee, setSelectedEmployee] = useState(null); - // Kalkulasi net salary secara real-time di sisi frontend + // Kalkulasi net salary const netSalary = data.details.reduce((acc, item) => { return item.type === 'bonus' ? acc + (item.amount || 0) : acc - (item.amount || 0); }, data.basic_salary); - // Auto-fill basic_salary saat employee dipilih + // Set basic_salary otomatis const handleEmployeeChange = (value: string) => { const emp = employees.find((e) => String(e.id) === value) ?? null; setSelectedEmployee(emp); @@ -90,7 +90,7 @@ export default function Create({ employees }: PageProps) { })); }; - // Tambah baris komponen (bonus/potongan) + // Tambah baris const addDetail = () => { setData('details', [ ...data.details, @@ -98,7 +98,7 @@ export default function Create({ employees }: PageProps) { ]); }; - // Update baris komponen pada index tertentu + // Update baris const updateDetail = (index: number, field: keyof DetailItem, value: string | number) => { const updated = [...data.details]; // @ts-expect-error – dynamic field assignment @@ -106,7 +106,7 @@ export default function Create({ employees }: PageProps) { setData('details', updated); }; - // Hapus baris komponen + // Hapus baris const removeDetail = (index: number) => { setData('details', data.details.filter((_, i) => i !== index)); }; @@ -278,56 +278,69 @@ export default function Create({ employees }: PageProps) { {data.details.map((item, index) => ( -
- {/* Nama Komponen */} - - updateDetail(index, 'name', e.target.value) - } - /> - - {/* Tipe */} - + {/* Nama Komponen */} + + updateDetail(index, 'name', e.target.value) + } + /> - {/* Nominal */} - - updateDetail(index, 'amount', e.target.value) - } - /> + {/* Tipe */} + - {/* Hapus */} - + {/* Nominal */} + + updateDetail(index, 'amount', e.target.value) + } + /> + + {/* Hapus */} + +
+ {/* Error per baris detail */} +
+ {errors[`details.${index}.name`] && ( +

{errors[`details.${index}.name`]}

+ )} + {errors[`details.${index}.type`] && ( +

{errors[`details.${index}.type`]}

+ )} + {errors[`details.${index}.amount`] && ( +

{errors[`details.${index}.amount`]}

+ )} +
))} diff --git a/resources/js/pages/admin/payrolls/edit.tsx b/resources/js/pages/admin/payrolls/edit.tsx new file mode 100644 index 0000000..bf91025 --- /dev/null +++ b/resources/js/pages/admin/payrolls/edit.tsx @@ -0,0 +1,273 @@ +import { Head, useForm, Link } from '@inertiajs/react'; +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import AppLayout from '@/layouts/app-layout'; +import { PlusCircle, Trash2 } from 'lucide-react'; + + + +interface DetailItem { + name: string; + type: 'bonus' | 'deduction'; + amount: number; +} + +interface PayrollData { + id: number; + period: string; + basic_salary: number; + details: DetailItem[]; + net_salary: number; + status: 'pending' | 'paid'; + employee: { + id: number; + name: string; + nip: string; + position: string; + department: string; + }; +} + +interface PageProps { + payroll: PayrollData; +} + + + +function formatRupiah(value: number): string { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(value); +} + +function formatPeriod(period: string): string { + const [year, month] = period.split('-'); + return new Date(Number(year), Number(month) - 1, 1) + .toLocaleDateString('id-ID', { month: 'long', year: 'numeric' }); +} + + + +export default function Edit({ payroll }: PageProps) { + const { data, setData, put, processing, errors } = useForm<{ + basic_salary: number; + details: DetailItem[]; + }>({ + basic_salary: payroll.basic_salary, + details: payroll.details, + }); + + // Hitung net salary + const netSalary = data.details.reduce((acc, item) => { + return item.type === 'bonus' + ? acc + (item.amount || 0) + : acc - (item.amount || 0); + }, data.basic_salary); + + const addDetail = () => { + setData('details', [...data.details, { name: '', type: 'bonus', amount: 0 }]); + }; + + const updateDetail = (index: number, field: keyof DetailItem, value: string | number) => { + const updated = [...data.details]; + // @ts-expect-error – dynamic field assignment + updated[index][field] = field === 'amount' ? Number(value) : value; + setData('details', updated); + }; + + const removeDetail = (index: number) => { + setData('details', data.details.filter((_, i) => i !== index)); + }; + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + put(`/admin/payrolls/${payroll.id}`); + }; + + return ( + + + +
+ + + + + Edit Payroll +

+ Revisi komponen gaji untuk periode ini. Karyawan dan periode tidak dapat diubah. +

+
+ + + + + {/* Info Payroll (read-only) */} +
+
+

Karyawan

+

{payroll.employee.name}

+
+
+

NIP

+

{payroll.employee.nip}

+
+
+

Jabatan

+

{payroll.employee.position ?? '-'}

+
+
+

Periode

+

{formatPeriod(payroll.period)}

+
+
+ +
+ + {/* ── Gaji Pokok ── */} +
+

+ 1 + Gaji Pokok +

+
+ + setData('basic_salary', Number(e.target.value))} + className="font-medium" + /> + {errors.basic_salary && ( +

{errors.basic_salary}

+ )} +
+
+ + + + {/* ── Komponen Bonus / Potongan ── */} +
+
+

+ 2 + Komponen Gaji +

+ +
+ + {data.details.length === 0 ? ( +

+ Belum ada komponen. Klik "Tambah Komponen" untuk menambah bonus atau potongan. +

+ ) : ( +
+
+ Nama Komponen + Tipe + Nominal (Rp) + +
+ {data.details.map((item, index) => ( +
+
+ updateDetail(index, 'name', e.target.value)} + /> + + updateDetail(index, 'amount', e.target.value)} + /> + +
+
+ {errors[`details.${index}.name` as keyof typeof errors] && ( +

{errors[`details.${index}.name` as keyof typeof errors]}

+ )} +
+
+ ))} +
+ )} +
+ + + + {/* ── Ringkasan Gaji Bersih ── */} +
+
+
+

Total Gaji Bersih (Estimasi)

+

Gaji Pokok + Bonus βˆ’ Potongan

+
+

+ {formatRupiah(Math.max(0, netSalary))} +

+
+
+ + {/* ── Tombol Aksi ── */} +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/resources/js/pages/admin/payrolls/index.tsx b/resources/js/pages/admin/payrolls/index.tsx index 5882678..8ec4e99 100644 --- a/resources/js/pages/admin/payrolls/index.tsx +++ b/resources/js/pages/admin/payrolls/index.tsx @@ -1,7 +1,15 @@ -import { Head, Link } from '@inertiajs/react'; +import { Head, Link, router, usePage } from '@inertiajs/react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Table, TableBody, @@ -11,7 +19,11 @@ import { TableRow, } from '@/components/ui/table'; import AppLayout from '@/layouts/app-layout'; -import { PlusCircle, FileText } from 'lucide-react'; +import { PlusCircle, FileText, Search, CheckCircle2, Clock, Pencil, Download } from 'lucide-react'; +import { useState } from 'react'; +import * as XLSX from 'xlsx'; + + interface Payroll { id: number; @@ -24,8 +36,12 @@ interface Payroll { interface PageProps { payrolls: Payroll[]; + filters: { search?: string; period?: string; status?: string }; + [key: string]: unknown; } + + function formatRupiah(value: number): string { return new Intl.NumberFormat('id-ID', { style: 'currency', @@ -42,41 +58,137 @@ function formatPeriod(period: string): string { function StatusBadge({ status }: { status: 'pending' | 'paid' }) { return status === 'paid' ? ( - Paid + + + Telah Dikirim + ) : ( - Pending + + + Menunggu + ); } -export default function Index({ payrolls }: PageProps) { + + +export default function Index({ payrolls, filters }: PageProps) { + const [search, setSearch] = useState(filters.search ?? ''); + const [period, setPeriod] = useState(filters.period ?? ''); + const [status, setStatus] = useState(filters.status ?? ''); + const [processingId, setProcessingId] = useState(null); + + const applyFilter = () => { + router.get('/admin/payrolls', { search, period, status }, { preserveScroll: true }); + }; + + const resetFilter = () => { + setSearch(''); setPeriod(''); setStatus(''); + router.get('/admin/payrolls', {}, { preserveScroll: true }); + }; + + const toggleStatus = (payroll: Payroll) => { + const newStatus = payroll.status === 'paid' ? 'pending' : 'paid'; + setProcessingId(payroll.id); + router.patch( + `/admin/payrolls/${payroll.id}/status`, + { status: newStatus }, + { preserveScroll: true, onFinish: () => setProcessingId(null) }, + ); + }; + + const exportExcel = () => { + const rows = payrolls.map((p, i) => ({ + No: i + 1, + Karyawan: p.employee_name, + NIP: p.employee_nip, + Periode: formatPeriod(p.period), + 'Gaji Bersih': p.net_salary, + Status: p.status === 'paid' ? 'Telah Dikirim' : 'Menunggu', + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Payroll'); + XLSX.writeFile(wb, `payroll_${new Date().toISOString().slice(0, 10)}.xlsx`); + }; + return (
-
-

Manajemen Payroll

-

Daftar seluruh payroll yang telah di-generate.

-
- - - - Daftar Slip Gaji +
+
+

Manajemen Payroll

+

Daftar seluruh payroll yang telah di-generate.

+
+
+ +
+
+ + {/* Filter */} + + +
+
+ +
+ + setSearch(e.target.value)} + onKeyDown={e => e.key === 'Enter' && applyFilter()} + /> +
+
+
+ + setPeriod(e.target.value)} + /> +
+
+ + +
+ + +
+
+
+ + {/* Tabel */} + + + Daftar Slip Gaji {payrolls.length === 0 ? (
-

- Belum ada data payroll. -

+

Belum ada data payroll.

@@ -96,28 +208,39 @@ export default function Index({ payrolls }: PageProps) { {payrolls.map((payroll, index) => ( - - {index + 1} - + {index + 1}
{payroll.employee_name}
-
- NIP: {payroll.employee_nip} -
+
NIP: {payroll.employee_nip}
{formatPeriod(payroll.period)} {formatRupiah(payroll.net_salary)} - + - +
+ + +
))} diff --git a/resources/js/pages/admin/payrolls/show.tsx b/resources/js/pages/admin/payrolls/show.tsx index ad174c2..5f388d2 100644 --- a/resources/js/pages/admin/payrolls/show.tsx +++ b/resources/js/pages/admin/payrolls/show.tsx @@ -1,12 +1,13 @@ -import { Head, Link } from '@inertiajs/react'; +import { Head, Link, router } from '@inertiajs/react'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import AppLayout from '@/layouts/app-layout'; -import { Printer, Building2, CalendarDays, Hash } from 'lucide-react'; +import { Printer, Building2, CalendarDays, Hash, Pencil, CheckCircle2, Clock } from 'lucide-react'; + -// ─── Types ─────────────────────────────────────────────────────────────────── interface DetailItem { name: string; @@ -34,7 +35,7 @@ interface PageProps { payroll: PayrollData; } -// ─── Helpers ───────────────────────────────────────────────────────────────── + function formatRupiah(value: number): string { return new Intl.NumberFormat('id-ID', { @@ -50,7 +51,7 @@ function formatPeriod(period: string): string { return date.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' }); } -// ─── Sub-components ─────────────────────────────────────────────────────────── + function InfoCell({ label, value }: { label: string; value: string }) { return ( @@ -90,23 +91,36 @@ function LineItem({ function StatusBadge({ status }: { status: 'pending' | 'paid' }) { return status === 'paid' ? ( - - Lunas + + + Telah Dikirim ) : ( - - Belum Dibayar + + + Menunggu ); } -// ─── Main Component ─────────────────────────────────────────────────────────── + export default function Show({ payroll }: PageProps) { const bonuses = payroll.details.filter((d) => d.type === 'bonus'); const deductions = payroll.details.filter((d) => d.type === 'deduction'); const totalBonus = bonuses.reduce((s, d) => s + d.amount, 0); const totalDeduction = deductions.reduce((s, d) => s + d.amount, 0); + const [toggling, setToggling] = useState(false); + + const toggleStatus = () => { + const newStatus = payroll.status === 'paid' ? 'pending' : 'paid'; + setToggling(true); + router.patch( + `/admin/payrolls/${payroll.id}/status`, + { status: newStatus }, + { onFinish: () => setToggling(false) }, + ); + }; return ( @@ -115,19 +129,45 @@ export default function Show({ payroll }: PageProps) {
{/* Toolbar β€” tersembunyi saat print */} -
+
- +
+ {payroll.status !== 'paid' && ( + <> + + + + )} + +
{/* ── Slip Card ── */} diff --git a/resources/js/pages/admin/positions/create.tsx b/resources/js/pages/admin/positions/create.tsx index 72cb184..c552483 100644 --- a/resources/js/pages/admin/positions/create.tsx +++ b/resources/js/pages/admin/positions/create.tsx @@ -78,7 +78,7 @@ export default function Create() {
- {/* Flash Messages */} - {flash?.success && ( -
- {flash.success} -
- )} - {flash?.error && ( -
- {flash.error} -
- )} - Daftar Jabatan - +
+ + +
+ +
+
+ + setSearch(e.target.value)} + /> +
+
+
diff --git a/resources/js/pages/admin/users/create.tsx b/resources/js/pages/admin/users/create.tsx index 179db02..32edd60 100644 --- a/resources/js/pages/admin/users/create.tsx +++ b/resources/js/pages/admin/users/create.tsx @@ -14,7 +14,7 @@ import { import { Separator } from '@/components/ui/separator'; import AppLayout from '@/layouts/app-layout'; -// ─── Types ─────────────────────────────────────────────────────────────────── + interface Employee { id: number; @@ -27,14 +27,12 @@ interface PageProps { employees: Employee[]; } -// ─── Component ─────────────────────────────────────────────────────────────── + export default function Create({ employees }: PageProps) { const { data, setData, post, processing, errors } = useForm({ employee_id: '', email: '', - password: '', - password_confirmation: '', role: '', }); @@ -125,31 +123,6 @@ export default function Create({ employees }: PageProps) { )} -
- - setData('password', e.target.value)} - /> - {errors.password && ( -

{errors.password}

- )} -
- -
- - setData('password_confirmation', e.target.value)} - /> -
{/* Role */}
@@ -170,6 +143,11 @@ export default function Create({ employees }: PageProps) { )}
+ {/* Helper text password default */} +

+ *password default user adalah 'password' +

+
diff --git a/resources/js/pages/admin/users/index.tsx b/resources/js/pages/admin/users/index.tsx index d2f8692..ffee8c9 100644 --- a/resources/js/pages/admin/users/index.tsx +++ b/resources/js/pages/admin/users/index.tsx @@ -4,9 +4,14 @@ import React, { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import AppLayout from '@/layouts/app-layout'; import { type BreadcrumbItem } from '@/types'; +import { Search } from 'lucide-react'; +import { useEffect } from 'react'; +import { useDebounce } from 'use-debounce'; +import * as XLSX from 'xlsx'; interface User { id: number; @@ -18,6 +23,7 @@ interface User { interface PageProps { users: User[]; + filters: { search?: string; role?: string }; [key: string]: unknown; } @@ -25,10 +31,35 @@ const breadcrumbs: BreadcrumbItem[] = [ { title: 'Manajemen Pengguna', href: '/admin/users' }, ]; -export default function Index({ users }: PageProps) { - const { flash } = usePage().props; +export default function Index({ users, filters }: PageProps) { + const [search, setSearch] = useState(filters.search || ''); + const [roleFilter, setRoleFilter] = useState(filters.role || 'all'); + const [debouncedSearch] = useDebounce(search, 500); const [processingId, setProcessingId] = useState(null); + useEffect(() => { + if (debouncedSearch !== filters.search || (roleFilter !== 'all' && roleFilter !== filters.role)) { + router.get( + '/admin/users', + { search: debouncedSearch, role: roleFilter === 'all' ? '' : roleFilter }, + { preserveState: true, replace: true } + ); + } + }, [debouncedSearch, roleFilter]); + + const exportExcel = () => { + const rows = users.map((u, i) => ({ + No: i + 1, + Nama: u.name, + Email: u.email, + Role: u.role.toUpperCase(), + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Pengguna'); + XLSX.writeFile(wb, `Data_Pengguna_${new Date().toISOString().slice(0, 10)}.xlsx`); + }; + const updateRole = (id: number, role: string) => { setProcessingId(id); router.patch( @@ -53,27 +84,42 @@ export default function Index({ users }: PageProps) {

Kelola akun dan hak akses pengguna sistem.

- {/* Flash Messages */} - {flash?.success && ( -
- {flash.success} -
- )} - {flash?.error && ( -
- {flash.error} -
- )} - - {/* Tabel */} Daftar Pengguna - +
+ + +
+ +
+
+
+ + setSearch(e.target.value)} + /> +
+
+
+ +
+
+
diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index f51d48c..111db38 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -36,25 +36,11 @@ export default function Login({ status, canResetPassword }: Props) { {/* Logo / Nama Aplikasi */}
-
- - - - - - +
+ Logo
-

HRIS App

-

Masuk ke panel manajemen

+

HRIS APP

+

PT. ZHANHUI JAYA INDONESIA

{/* Card Login */} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index f631727..3aae650 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,7 +1,7 @@ import { Head, Link } from '@inertiajs/react'; import AppLayout from '@/layouts/app-layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Users, Building2, Briefcase, UserPlus } from 'lucide-react'; +import { Users, Building2, Briefcase, UserPlus, Banknote } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend @@ -12,13 +12,35 @@ interface DashboardProps { total_employees: number; total_departments: number; total_positions: number; + payroll_realisasi: number; }; - genderData: any[]; - deptData: any[]; - latestEmployees: any[]; + genderData: { name: string; value: number; fill: string }[]; + deptData: { name: string; employees: number }[]; + latestEmployees: { + id: number; + join_date: string; + user: { name: string }; + position: { name: string }; + department: { name: string }; + }[]; + currentPeriod: string; } -export default function Dashboard({ stats, genderData, deptData, latestEmployees }: DashboardProps) { +function formatRupiah(value: number): string { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(value); +} + +function formatPeriod(period: string): string { + const [year, month] = period.split('-'); + return new Date(Number(year), Number(month) - 1, 1) + .toLocaleDateString('id-ID', { month: 'long', year: 'numeric' }); +} + +export default function Dashboard({ stats, genderData, deptData, latestEmployees, currentPeriod }: DashboardProps) { return ( @@ -26,7 +48,7 @@ export default function Dashboard({ stats, genderData, deptData, latestEmployees
{/* CARD STATISTIK */} -
+
Total Karyawan @@ -57,6 +79,23 @@ export default function Dashboard({ stats, genderData, deptData, latestEmployees

Posisi pekerjaan

+ {/* Card Realisasi Gaji */} + + + + Realisasi Gaji Bulan Ini + + + + +
+ {formatRupiah(stats.payroll_realisasi)} +
+

+ Status "Telah Dikirim" Β· {formatPeriod(currentPeriod)} +

+
+
@@ -137,14 +176,16 @@ export default function Dashboard({ stats, genderData, deptData, latestEmployees
- {latestEmployees.map((emp) => ( + {latestEmployees.length === 0 ? ( +

Belum ada data karyawan.

+ ) : latestEmployees.map((emp) => (
-

{emp.user.name}

-

{emp.position.name} β€’ {emp.department.name}

+

{emp.user?.name ?? '-'}

+

{emp.position?.name ?? '-'} β€’ {emp.department?.name ?? '-'}

{new Date(emp.join_date).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })} diff --git a/resources/js/pages/employee/attendances/index.tsx b/resources/js/pages/employee/attendances/index.tsx index 19b0238..ba11adf 100644 --- a/resources/js/pages/employee/attendances/index.tsx +++ b/resources/js/pages/employee/attendances/index.tsx @@ -3,13 +3,14 @@ import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import AppLayout from '@/layouts/app-layout'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import EmployeeLayout from '@/layouts/employee-layout'; +import { AttendanceMapModal } from '@/components/AttendanceMapModal'; +import { MapPin, AlertCircle, RefreshCw, CheckCircle2, LogOut } from 'lucide-react'; -// Define expected prop types interface Attendance { id: number; date: string; @@ -17,6 +18,10 @@ interface Attendance { check_out: string | null; status: 'present' | 'leave' | 'dispensation'; notes: string | null; + latitude_in?: string | null; + longitude_in?: string | null; + latitude_out?: string | null; + longitude_out?: string | null; } interface PageProps { @@ -25,56 +30,31 @@ interface PageProps { [key: string]: unknown; } -interface SharedData { - flash: { - success?: string; - error?: string; - }; -} - -export default function Index({ attendances, todayAttendance }: PageProps) { - const { flash } = usePage().props as SharedData; +export default function AttendanceIndex({ attendances, todayAttendance }: PageProps) { + const { flash } = usePage().props; const [locationError, setLocationError] = useState(null); const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null); - const [isLoadingLocation, setIsLoadingLocation] = useState(false); + const [isLoadingLocation, setIsLoadingLocation] = useState(true); - const { - data: regulerData, - setData: setRegulerData, - post: postReguler, - processing: processingReguler - } = useForm({ + const { data: regulerData, setData: setRegulerData, post: postReguler, processing: processingReguler } = useForm({ latitude_in: '', longitude_in: '', latitude_out: '', longitude_out: '', }); - const { - data: leaveData, - setData: setLeaveData, - post: postLeave, - processing: processingLeave, - reset: resetLeave - } = useForm({ + const { data: leaveData, setData: setLeaveData, post: postLeave, processing: processingLeave, reset: resetLeave, errors: leaveErrors } = useForm({ date: '', notes: '', }); - const { - data: dispenData, - setData: setDispenData, - post: postDispen, - processing: processingDispen, - reset: resetDispen - } = useForm({ + const { data: dispenData, setData: setDispenData, post: postDispen, processing: processingDispen, reset: resetDispen, errors: dispenErrors } = useForm({ latitude_in: '', longitude_in: '', notes: '', }); - // Mendapatkan lokasi saat komponen dimuat atau tombol ditekan - const getLocation = (callback?: (lat: number, lng: number) => void) => { + const getLocation = () => { setIsLoadingLocation(true); setLocationError(null); @@ -102,11 +82,10 @@ export default function Index({ attendances, todayAttendance }: PageProps) { longitude_in: lng.toString(), }); setIsLoadingLocation(false); - if (callback) callback(lat, lng); }, (error) => { let errorMsg = 'Gagal mengambil lokasi.'; - if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi.'; + if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi di pengaturan browser/perangkat Anda.'; setLocationError(errorMsg); setIsLoadingLocation(false); }, @@ -120,24 +99,12 @@ export default function Index({ attendances, todayAttendance }: PageProps) { const handleClockIn = (e: React.FormEvent) => { e.preventDefault(); - if (!coordinates) { - getLocation((lat, lng) => { - postReguler('/employee/attendances/clock-in'); - }); - } else { - postReguler('/employee/attendances/clock-in'); - } + postReguler('/employee/attendances/clock-in'); }; const handleClockOut = (e: React.FormEvent) => { e.preventDefault(); - if (!coordinates) { - getLocation((lat, lng) => { - postReguler('/employee/attendances/clock-out'); - }); - } else { - postReguler('/employee/attendances/clock-out'); - } + postReguler('/employee/attendances/clock-out'); }; const handleLeaveSubmit = (e: React.FormEvent) => { @@ -149,17 +116,9 @@ export default function Index({ attendances, todayAttendance }: PageProps) { const handleDispenSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!coordinates) { - getLocation((lat, lng) => { - postDispen('/employee/attendances/dispensation', { - onSuccess: () => resetDispen() - }); - }); - } else { - postDispen('/employee/attendances/dispensation', { - onSuccess: () => resetDispen() - }); - } + postDispen('/employee/attendances/dispensation', { + onSuccess: () => resetDispen() + }); }; const formatStatus = (status: string) => { @@ -171,208 +130,215 @@ export default function Index({ attendances, todayAttendance }: PageProps) { } }; + const isClockInDisabled = processingReguler || isLoadingLocation || !coordinates || Boolean(todayAttendance?.check_in); + const isClockOutDisabled = processingReguler || isLoadingLocation || !coordinates || !todayAttendance || Boolean(todayAttendance?.check_out); + return ( - - - -
+ +
+
+
+

Portal Absensi

+

Lakukan absen masuk, pulang, atau ajukan izin.

+
+
+
+ +
- {/* Header Section */} -
-

Portal Absensi

-

Lakukan absen masuk/pulang, atau ajukan izin dan dispensasi.

+
+ {isLoadingLocation ? ( + + ) : locationError ? ( + + ) : ( + + )} +
+
+

+ {isLoadingLocation ? 'Mencari lokasi Anda...' : + locationError ? 'Gagal Akses Lokasi' : + 'Lokasi Ditemukan'} +

+

+ {isLoadingLocation ? 'Harap tunggu, pastikan GPS aktif.' : + locationError ? locationError : + `Koordinat: ${coordinates?.lat.toFixed(6)}, ${coordinates?.lng.toFixed(6)}`} +

+
+ {locationError && ( + + )} +
- {/* Flash Messages */} - {flash?.success && ( -
- {flash.success} -
- )} - {flash?.error && ( -
- {flash.error} -
- )} - {locationError && ( -
- {locationError} -
- )} +
+
+ + + + + Harian + Izin + Dispen + -
- {/* Left Column: Forms */} -
- - - Harian - Izin - Dispen - - - {/* Tab Absen Reguler */} - - - - Absen Reguler - - {todayAttendance - ? 'Anda sudah memiliki catatan absensi hari ini.' - : 'Sistem membutuhkan akses lokasi untuk mencatat absensi.'} - - - -
-

Lokasi Saat Ini:

-

- {isLoadingLocation ? 'Mengambil lokasi...' : (coordinates ? `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}` : 'Lokasi tidak tersedia')} + +

+

Absen Harian

+

+ {todayAttendance + ? 'Anda sudah memiliki catatan absensi hari ini.' + : 'Sistem membutuhkan akses lokasi.'}

- -
+
- - - + - {/* Tab Izin */} - - - - Form Izin / Cuti - Ajukan izin tidak masuk kerja. - - +
- + setLeaveData('date', e.target.value)} + className="h-11" /> + {leaveErrors.date &&

{leaveErrors.date}

}
- +