From a3ad24e029ace80b1fdb04824d59d06ff380207c Mon Sep 17 00:00:00 2001
From: IlhamIslamy
Date: Mon, 27 Apr 2026 22:45:22 +0700
Subject: [PATCH] feat: implement attendance tracking system with admin and
employee dashboards
---
.../Admin/AttendanceController.php | 45 +++
.../Employee/EmployeeAttendanceController.php | 153 +++++++
app/Http/Middleware/HandleInertiaRequests.php | 4 +
app/Models/Attendance.php | 30 ++
..._04_27_000001_create_attendances_table.php | 31 ++
database/seeders/EmployeeSeeder.php | 42 +-
package-lock.json | 23 +-
resources/js/components/app-sidebar.tsx | 14 +-
resources/js/components/ui/tabs.tsx | 89 +++++
resources/js/components/ui/textarea.tsx | 18 +
.../js/pages/admin/attendances/index.tsx | 101 +++++
.../js/pages/employee/attendances/index.tsx | 378 ++++++++++++++++++
resources/js/pages/employee/index.tsx | 14 +-
routes/web.php | 9 +-
14 files changed, 925 insertions(+), 26 deletions(-)
create mode 100644 app/Http/Controllers/Admin/AttendanceController.php
create mode 100644 app/Http/Controllers/Employee/EmployeeAttendanceController.php
create mode 100644 app/Models/Attendance.php
create mode 100644 database/migrations/2026_04_27_000001_create_attendances_table.php
create mode 100644 resources/js/components/ui/tabs.tsx
create mode 100644 resources/js/components/ui/textarea.tsx
create mode 100644 resources/js/pages/admin/attendances/index.tsx
create mode 100644 resources/js/pages/employee/attendances/index.tsx
diff --git a/app/Http/Controllers/Admin/AttendanceController.php b/app/Http/Controllers/Admin/AttendanceController.php
new file mode 100644
index 0000000..e798f3f
--- /dev/null
+++ b/app/Http/Controllers/Admin/AttendanceController.php
@@ -0,0 +1,45 @@
+orderBy('date', 'desc')->get();
+
+ $attendances = $attendances->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,
+ '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,
+ ];
+ });
+
+ return Inertia::render('admin/attendances/index', [
+ 'attendances' => $attendances
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Employee/EmployeeAttendanceController.php b/app/Http/Controllers/Employee/EmployeeAttendanceController.php
new file mode 100644
index 0000000..5d6d147
--- /dev/null
+++ b/app/Http/Controllers/Employee/EmployeeAttendanceController.php
@@ -0,0 +1,153 @@
+user()->employee;
+ }
+
+ public function index(Request $request)
+ {
+ $employee = $this->checkEmployee($request);
+ if (!$employee) {
+ return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
+ }
+
+ $employeeId = $employee->id;
+ $attendances = Attendance::where('employee_id', $employeeId)->orderBy('date', 'desc')->get();
+
+ $today = Carbon::today()->toDateString();
+ $todayAttendance = Attendance::where('employee_id', $employeeId)->where('date', $today)->first();
+
+ return Inertia::render('employee/attendances/index', [
+ 'attendances' => $attendances,
+ 'todayAttendance' => $todayAttendance,
+ ]);
+ }
+
+ public function clockIn(Request $request)
+ {
+ $employee = $this->checkEmployee($request);
+ if (!$employee) {
+ return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
+ }
+
+ $request->validate([
+ 'latitude_in' => 'required|numeric',
+ 'longitude_in' => 'required|numeric',
+ ]);
+
+ $employeeId = $employee->id;
+ $today = Carbon::today()->toDateString();
+
+ $existingAttendance = Attendance::where('employee_id', $employeeId)
+ ->where('date', $today)
+ ->first();
+
+ if ($existingAttendance) {
+ return back()->with('error', 'Anda sudah melakukan absen masuk hari ini.');
+ }
+
+ Attendance::create([
+ 'employee_id' => $employeeId,
+ 'date' => $today,
+ 'check_in' => Carbon::now()->toTimeString(),
+ 'latitude_in' => $request->latitude_in,
+ 'longitude_in' => $request->longitude_in,
+ 'status' => 'present',
+ ]);
+
+ return back()->with('success', 'Berhasil Absen Masuk.');
+ }
+
+ public function clockOut(Request $request)
+ {
+ $employee = $this->checkEmployee($request);
+ if (!$employee) {
+ return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
+ }
+
+ $request->validate([
+ 'latitude_out' => 'required|numeric',
+ 'longitude_out' => 'required|numeric',
+ ]);
+
+ $employeeId = $employee->id;
+ $today = Carbon::today()->toDateString();
+
+ $attendance = Attendance::where('employee_id', $employeeId)
+ ->where('date', $today)
+ ->first();
+
+ if (!$attendance || $attendance->check_out) {
+ return back()->with('error', 'Anda belum absen masuk atau sudah absen pulang hari ini.');
+ }
+
+ $attendance->update([
+ 'check_out' => Carbon::now()->toTimeString(),
+ 'latitude_out' => $request->latitude_out,
+ 'longitude_out' => $request->longitude_out,
+ ]);
+
+ return back()->with('success', 'Berhasil Absen Pulang.');
+ }
+
+ public function submitLeave(Request $request)
+ {
+ $employee = $this->checkEmployee($request);
+ if (!$employee) {
+ return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
+ }
+
+ $request->validate([
+ 'date' => 'required|date',
+ 'notes' => 'required|string',
+ ]);
+
+ Attendance::create([
+ 'employee_id' => $employee->id,
+ 'date' => $request->date,
+ 'status' => 'leave',
+ 'notes' => $request->notes,
+ ]);
+
+ return back()->with('success', 'Izin berhasil diajukan.');
+ }
+
+ public function submitDispensation(Request $request)
+ {
+ $employee = $this->checkEmployee($request);
+ if (!$employee) {
+ return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
+ }
+
+ $request->validate([
+ 'latitude_in' => 'required|numeric',
+ 'longitude_in' => 'required|numeric',
+ 'notes' => 'required|string',
+ ]);
+
+ $now = Carbon::now();
+
+ Attendance::create([
+ 'employee_id' => $employee->id,
+ 'date' => $now->toDateString(),
+ 'check_in' => $now->toTimeString(),
+ 'latitude_in' => $request->latitude_in,
+ 'longitude_in' => $request->longitude_in,
+ 'status' => 'dispensation',
+ 'notes' => $request->notes,
+ ]);
+
+ return back()->with('success', 'Dispensasi berhasil diajukan.');
+ }
+}
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index 652ec83..6a05f06 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -45,6 +45,10 @@ public function share(Request $request): array
'role' => $request->user()->role,
] : null,
],
+ 'flash' => [
+ 'success' => fn () => $request->session()->get('success'),
+ 'error' => fn () => $request->session()->get('error'),
+ ],
];
}
}
diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php
new file mode 100644
index 0000000..bd28343
--- /dev/null
+++ b/app/Models/Attendance.php
@@ -0,0 +1,30 @@
+belongsTo(Employee::class);
+ }
+}
diff --git a/database/migrations/2026_04_27_000001_create_attendances_table.php b/database/migrations/2026_04_27_000001_create_attendances_table.php
new file mode 100644
index 0000000..0ee6805
--- /dev/null
+++ b/database/migrations/2026_04_27_000001_create_attendances_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->foreignId('employee_id')->constrained()->onDelete('cascade');
+ $table->date('date');
+ $table->time('check_in')->nullable();
+ $table->time('check_out')->nullable();
+ $table->decimal('latitude_in', 10, 8)->nullable();
+ $table->decimal('longitude_in', 11, 8)->nullable();
+ $table->decimal('latitude_out', 10, 8)->nullable();
+ $table->decimal('longitude_out', 11, 8)->nullable();
+ $table->enum('status', ['present', 'leave', 'dispensation'])->default('present');
+ $table->text('notes')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('attendances');
+ }
+};
diff --git a/database/seeders/EmployeeSeeder.php b/database/seeders/EmployeeSeeder.php
index 566f543..99a9141 100644
--- a/database/seeders/EmployeeSeeder.php
+++ b/database/seeders/EmployeeSeeder.php
@@ -13,49 +13,49 @@ class EmployeeSeeder extends Seeder
{
public function run(): void
{
- $depts = Department::pluck('id', 'name');
+ $depts = Department::pluck('id', 'name');
$positions = Position::pluck('id', 'name');
$employees = [
[
- 'user' => ['name' => 'Jono Joni', 'email' => 'jono12@gmail.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2023001', 'name' => 'Jono Joni', 'gender' => 'L', 'place_of_birth' => 'Jakarta', 'birth_date' => '1992-05-14', 'address' => 'Jl. Merdeka No.12, Jakarta Pusat', 'phone_number' => '081234567890', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Senior Developer'], 'status' => 'PKWTT', 'join_date' => '2023-01-15'],
+ 'user' => ['name' => 'Jono Joni', 'email' => 'jono12@gmail.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2023001', 'name' => 'Jono Joni', 'gender' => 'L', 'place_of_birth' => 'Jakarta', 'birth_date' => '1992-05-14', 'address' => 'Jl. Merdeka No.12, Jakarta Pusat', 'phone_number' => '081234567890', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Senior Developer'], 'status' => 'PKWTT', 'join_date' => '2023-01-15'],
],
[
- 'user' => ['name' => 'Budi Santoso', 'email' => 'budi.santoso12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2023002', 'name' => 'Budi Santoso', 'gender' => 'L', 'place_of_birth' => 'Bandung', 'birth_date' => '1995-08-22', 'address' => 'Jl. Sukajadi No.45, Bandung', 'phone_number' => '082345678901', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'PKWT', 'join_date' => '2023-03-01'],
+ 'user' => ['name' => 'Budi Santoso', 'email' => 'budi.santoso12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2023002', 'name' => 'Budi Santoso', 'gender' => 'L', 'place_of_birth' => 'Bandung', 'birth_date' => '1995-08-22', 'address' => 'Jl. Sukajadi No.45, Bandung', 'phone_number' => '082345678901', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'PKWT', 'join_date' => '2023-03-01'],
],
[
- 'user' => ['name' => 'Rizky Firmansyah', 'email' => 'rizky.firmansyah12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2024001', 'name' => 'Rizky Firmansyah', 'gender' => 'L', 'place_of_birth' => 'Surabaya', 'birth_date' => '1998-11-30', 'address' => 'Jl. Pemuda No.7, Surabaya', 'phone_number' => '083456789012', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'Magang','join_date' => '2024-02-01'],
+ 'user' => ['name' => 'Rizky Firmansyah', 'email' => 'rizky.firmansyah12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2024001', 'name' => 'Rizky Firmansyah', 'gender' => 'L', 'place_of_birth' => 'Surabaya', 'birth_date' => '1998-11-30', 'address' => 'Jl. Pemuda No.7, Surabaya', 'phone_number' => '083456789012', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'Magang', 'join_date' => '2024-02-01'],
],
[
- 'user' => ['name' => 'Dewi Rahayu', 'email' => 'dewi.rahayu12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2022001', 'name' => 'Dewi Rahayu', 'gender' => 'P', 'place_of_birth' => 'Yogyakarta', 'birth_date' => '1990-03-10', 'address' => 'Jl. Malioboro No.88, Yogyakarta', 'phone_number' => '084567890123', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['Manager'], 'status' => 'PKWTT', 'join_date' => '2022-06-01'],
+ 'user' => ['name' => 'Dewi Rahayu', 'email' => 'dewi.rahayu12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2022001', 'name' => 'Dewi Rahayu', 'gender' => 'P', 'place_of_birth' => 'Yogyakarta', 'birth_date' => '1990-03-10', 'address' => 'Jl. Malioboro No.88, Yogyakarta', 'phone_number' => '084567890123', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['Manager'], 'status' => 'PKWTT', 'join_date' => '2022-06-01'],
],
[
- 'user' => ['name' => 'Anisa Putri', 'email' => 'anisa.putri12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2023003', 'name' => 'Anisa Putri', 'gender' => 'P', 'place_of_birth' => 'Semarang', 'birth_date' => '1997-07-19', 'address' => 'Jl. Pahlawan No.3, Semarang', 'phone_number' => '085678901234', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['HR Specialist'], 'status' => 'PKWTT', 'join_date' => '2023-07-01'],
+ 'user' => ['name' => 'Anisa Putri', 'email' => 'anisa.putri12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2023003', 'name' => 'Anisa Putri', 'gender' => 'P', 'place_of_birth' => 'Semarang', 'birth_date' => '1997-07-19', 'address' => 'Jl. Pahlawan No.3, Semarang', 'phone_number' => '085678901234', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['HR Specialist'], 'status' => 'PKWTT', 'join_date' => '2023-07-01'],
],
[
- 'user' => ['name' => 'Hendra Kurniawan', 'email' => 'hendra.kurniawan12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2021001', 'name' => 'Hendra Kurniawan', 'gender' => 'L', 'place_of_birth' => 'Medan', 'birth_date' => '1988-12-05', 'address' => 'Jl. Sudirman No.22, Medan', 'phone_number' => '086789012345', 'department_id' => $depts['Finance'], 'position_id' => $positions['Finance Analyst'], 'status' => 'PKWTT', 'join_date' => '2021-04-01'],
+ 'user' => ['name' => 'Hendra Kurniawan', 'email' => 'hendra.kurniawan12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2021001', 'name' => 'Hendra Kurniawan', 'gender' => 'L', 'place_of_birth' => 'Medan', 'birth_date' => '1988-12-05', 'address' => 'Jl. Sudirman No.22, Medan', 'phone_number' => '086789012345', 'department_id' => $depts['Finance'], 'position_id' => $positions['Finance Analyst'], 'status' => 'PKWTT', 'join_date' => '2021-04-01'],
],
[
- 'user' => ['name' => 'Sari Wulandari', 'email' => 'sari.wulandari12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2023004', 'name' => 'Sari Wulandari', 'gender' => 'P', 'place_of_birth' => 'Solo', 'birth_date' => '1996-02-28', 'address' => 'Jl. Brigjen Katamso No.5, Solo', 'phone_number' => '087890123456', 'department_id' => $depts['Finance'], 'position_id' => $positions['Staff Admin'], 'status' => 'PKWT', 'join_date' => '2023-09-01'],
+ 'user' => ['name' => 'Sari Wulandari', 'email' => 'sari.wulandari12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2023004', 'name' => 'Sari Wulandari', 'gender' => 'P', 'place_of_birth' => 'Solo', 'birth_date' => '1996-02-28', 'address' => 'Jl. Brigjen Katamso No.5, Solo', 'phone_number' => '087890123456', 'department_id' => $depts['Finance'], 'position_id' => $positions['Staff Admin'], 'status' => 'PKWT', 'join_date' => '2023-09-01'],
],
[
- 'user' => ['name' => 'Fajar Nugroho', 'email' => 'fajar.nugroho12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2022002', 'name' => 'Fajar Nugroho', 'gender' => 'L', 'place_of_birth' => 'Makassar', 'birth_date' => '1993-09-15', 'address' => 'Jl. Sultan Hasanuddin No.10, Makassar', 'phone_number' => '088901234567', 'department_id' => $depts['Marketing'], 'position_id' => $positions['Marketing Staff'], 'status' => 'PKWTT', 'join_date' => '2022-11-01'],
+ 'user' => ['name' => 'Fajar Nugroho', 'email' => 'fajar.nugroho12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2022002', 'name' => 'Fajar Nugroho', 'gender' => 'L', 'place_of_birth' => 'Makassar', 'birth_date' => '1993-09-15', 'address' => 'Jl. Sultan Hasanuddin No.10, Makassar', 'phone_number' => '088901234567', 'department_id' => $depts['Marketing'], 'position_id' => $positions['Marketing Staff'], 'status' => 'PKWTT', 'join_date' => '2022-11-01'],
],
[
- 'user' => ['name' => 'Linda Permatasari','email' => 'linda.permata12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2024002', 'name' => 'Linda Permatasari','gender' => 'P', 'place_of_birth' => 'Palembang', 'birth_date' => '1999-04-20', 'address' => 'Jl. Demang Lebar Daun No.8, Palembang', 'phone_number' => '089012345678', 'department_id' => $depts['Operations'], 'position_id' => $positions['Operations Staff'], 'status' => 'PKWT', 'join_date' => '2024-01-10'],
+ 'user' => ['name' => 'Linda Permatasari', 'email' => 'linda.permata12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2024002', 'name' => 'Linda Permatasari', 'gender' => 'P', 'place_of_birth' => 'Palembang', 'birth_date' => '1999-04-20', 'address' => 'Jl. Demang Lebar Daun No.8, Palembang', 'phone_number' => '089012345678', 'department_id' => $depts['Operations'], 'position_id' => $positions['Operations Staff'], 'status' => 'PKWT', 'join_date' => '2024-01-10'],
],
[
- 'user' => ['name' => 'Agus Prasetyo', 'email' => 'agus.prasetyo12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
- 'employee' => ['nip' => '2020001', 'name' => 'Agus Prasetyo', 'gender' => 'L', 'place_of_birth' => 'Malang', 'birth_date' => '1985-06-10', 'address' => 'Jl. Ijen No.33, Malang', 'phone_number' => '081112233445', 'department_id' => $depts['Operations'], 'position_id' => $positions['Manager'], 'status' => 'PKWTT', 'join_date' => '2020-08-01'],
+ 'user' => ['name' => 'Agus Prasetyo', 'email' => 'agus.prasetyo12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
+ 'employee' => ['nip' => '2020001', 'name' => 'Agus Prasetyo', 'gender' => 'L', 'place_of_birth' => 'Malang', 'birth_date' => '1985-06-10', 'address' => 'Jl. Ijen No.33, Malang', 'phone_number' => '081112233445', 'department_id' => $depts['Operations'], 'position_id' => $positions['Manager'], 'status' => 'PKWTT', 'join_date' => '2020-08-01'],
],
];
diff --git a/package-lock.json b/package-lock.json
index cb02e25..b2346d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -94,6 +94,7 @@
"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",
@@ -5537,6 +5538,7 @@
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -5546,6 +5548,7 @@
"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"
}
@@ -5555,6 +5558,7 @@
"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"
}
@@ -5610,6 +5614,7 @@
"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",
@@ -6129,6 +6134,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6465,6 +6471,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7277,6 +7284,7 @@
"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",
@@ -7463,6 +7471,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -9574,6 +9583,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9925,6 +9935,7 @@
"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"
}
@@ -9934,6 +9945,7 @@
"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"
},
@@ -9945,13 +9957,15 @@
"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"
+ "license": "MIT",
+ "peer": true
},
"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"
@@ -10082,7 +10096,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -10922,6 +10937,7 @@
"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"
@@ -10987,6 +11003,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -11146,6 +11163,7 @@
"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",
@@ -11430,6 +11448,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx
index 3c18048..0796a99 100644
--- a/resources/js/components/app-sidebar.tsx
+++ b/resources/js/components/app-sidebar.tsx
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Link, usePage } from '@inertiajs/react';
-import { LayoutGrid, Users, Briefcase, Building2, SquareUser, Wallet } from 'lucide-react';
+import { LayoutGrid, Users, Briefcase, Building2, SquareUser, Wallet, CalendarClock } from 'lucide-react';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
import {
@@ -47,12 +47,24 @@ export function AppSidebar() {
href: '/admin/payrolls',
icon: Wallet,
},
+ {
+ title: 'Manajemen Absensi',
+ href: '/admin/attendances',
+ icon: CalendarClock,
+ },
{
title: 'Manajemen Pengguna',
href: '/admin/users',
icon: SquareUser,
},
] : []),
+ ...(userRole === 'employee' ? [
+ {
+ title: 'Absensi',
+ href: '/employee/attendances',
+ icon: CalendarClock,
+ },
+ ] : []),
];
return (
diff --git a/resources/js/components/ui/tabs.tsx b/resources/js/components/ui/tabs.tsx
new file mode 100644
index 0000000..7f2c4f2
--- /dev/null
+++ b/resources/js/components/ui/tabs.tsx
@@ -0,0 +1,89 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/resources/js/components/ui/textarea.tsx b/resources/js/components/ui/textarea.tsx
new file mode 100644
index 0000000..e67d8fe
--- /dev/null
+++ b/resources/js/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/resources/js/pages/admin/attendances/index.tsx b/resources/js/pages/admin/attendances/index.tsx
new file mode 100644
index 0000000..8fb442d
--- /dev/null
+++ b/resources/js/pages/admin/attendances/index.tsx
@@ -0,0 +1,101 @@
+import { Head, usePage } from '@inertiajs/react';
+import React from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Separator } from '@/components/ui/separator';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import AppLayout from '@/layouts/app-layout';
+
+interface Attendance {
+ id: number;
+ employee_name: string;
+ employee_nik: string;
+ date: string;
+ check_in: string | null;
+ check_out: string | null;
+ status: 'present' | 'leave' | 'dispensation';
+ notes: string | null;
+ total_hours: string | null;
+}
+
+interface PageProps {
+ attendances: Attendance[];
+ [key: string]: unknown;
+}
+
+export default function Index({ attendances }: PageProps) {
+ const formatStatus = (status: string) => {
+ switch (status) {
+ case 'present': return Hadir;
+ case 'leave': return Izin/Cuti;
+ case 'dispensation': return Dispensasi;
+ default: return {status};
+ }
+ };
+
+ return (
+
+
+
+
+
+
Rekap Absensi Karyawan
+
Monitoring kehadiran, izin, dan dispensasi karyawan.
+
+
+
+
+ Daftar Absensi
+
+
+
+
+
+
+
+ Nama
+ NIK
+ Tanggal
+ Jam Masuk
+ Jam Keluar
+ Keterangan
+ Total Jam Kerja
+
+
+
+ {attendances.length > 0 ? (
+ attendances.map((attendance) => (
+
+ {attendance.employee_name}
+ {attendance.employee_nik}
+ {attendance.date}
+ {attendance.check_in || '-'}
+ {attendance.check_out || '-'}
+
+
+ {formatStatus(attendance.status)}
+ {attendance.notes && (
+ {attendance.notes}
+ )}
+
+
+
+ {attendance.total_hours ? `${attendance.total_hours} Jam` : '-'}
+
+
+ ))
+ ) : (
+
+
+ Belum ada data absensi.
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/pages/employee/attendances/index.tsx b/resources/js/pages/employee/attendances/index.tsx
new file mode 100644
index 0000000..19b0238
--- /dev/null
+++ b/resources/js/pages/employee/attendances/index.tsx
@@ -0,0 +1,378 @@
+import { Head, useForm, usePage } from '@inertiajs/react';
+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';
+
+// Define expected prop types
+interface Attendance {
+ id: number;
+ date: string;
+ check_in: string | null;
+ check_out: string | null;
+ status: 'present' | 'leave' | 'dispensation';
+ notes: string | null;
+}
+
+interface PageProps {
+ attendances: Attendance[];
+ todayAttendance: Attendance | null;
+ [key: string]: unknown;
+}
+
+interface SharedData {
+ flash: {
+ success?: string;
+ error?: string;
+ };
+}
+
+export default function Index({ attendances, todayAttendance }: PageProps) {
+ const { flash } = usePage().props as SharedData;
+ const [locationError, setLocationError] = useState(null);
+ const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
+ const [isLoadingLocation, setIsLoadingLocation] = useState(false);
+
+ 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({
+ date: '',
+ notes: '',
+ });
+
+ const {
+ data: dispenData,
+ setData: setDispenData,
+ post: postDispen,
+ processing: processingDispen,
+ reset: resetDispen
+ } = useForm({
+ latitude_in: '',
+ longitude_in: '',
+ notes: '',
+ });
+
+ // Mendapatkan lokasi saat komponen dimuat atau tombol ditekan
+ const getLocation = (callback?: (lat: number, lng: number) => void) => {
+ setIsLoadingLocation(true);
+ setLocationError(null);
+
+ if (!navigator.geolocation) {
+ setLocationError('Geolocation tidak didukung oleh browser ini.');
+ setIsLoadingLocation(false);
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const lat = position.coords.latitude;
+ const lng = position.coords.longitude;
+ setCoordinates({ lat, lng });
+ setRegulerData({
+ ...regulerData,
+ latitude_in: lat.toString(),
+ longitude_in: lng.toString(),
+ latitude_out: lat.toString(),
+ longitude_out: lng.toString(),
+ });
+ setDispenData({
+ ...dispenData,
+ latitude_in: lat.toString(),
+ 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.';
+ setLocationError(errorMsg);
+ setIsLoadingLocation(false);
+ },
+ { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
+ );
+ };
+
+ useEffect(() => {
+ getLocation();
+ }, []);
+
+ const handleClockIn = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!coordinates) {
+ getLocation((lat, lng) => {
+ postReguler('/employee/attendances/clock-in');
+ });
+ } else {
+ 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');
+ }
+ };
+
+ const handleLeaveSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ postLeave('/employee/attendances/leave', {
+ onSuccess: () => resetLeave()
+ });
+ };
+
+ 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()
+ });
+ }
+ };
+
+ const formatStatus = (status: string) => {
+ switch (status) {
+ case 'present': return 'Hadir';
+ case 'leave': return 'Izin/Cuti';
+ case 'dispensation': return 'Dispensasi';
+ default: return status;
+ }
+ };
+
+ return (
+
+
+
+
+
+ {/* Header Section */}
+
+
Portal Absensi
+
Lakukan absen masuk/pulang, atau ajukan izin dan dispensasi.
+
+
+ {/* Flash Messages */}
+ {flash?.success && (
+
+ {flash.success}
+
+ )}
+ {flash?.error && (
+
+ {flash.error}
+
+ )}
+ {locationError && (
+
+ {locationError}
+
+ )}
+
+
+ {/* 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')}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Tab Izin */}
+
+
+
+ Form Izin / Cuti
+ Ajukan izin tidak masuk kerja.
+
+
+
+
+
+
+
+ {/* Tab Dispensasi */}
+
+
+
+ Form Dispensasi
+ Dispensasi tugas luar. Memerlukan lokasi.
+
+
+
+
+
+
+
+
+
+ {/* Right Column: History */}
+
+
+
+ Riwayat Absensi
+
+
+
+
+
+
+ Tanggal
+ Jam Masuk
+ Jam Keluar
+ Status
+ Keterangan
+
+
+
+ {attendances.length > 0 ? (
+ attendances.map((att) => (
+
+ {att.date}
+ {att.check_in || '-'}
+ {att.check_out || '-'}
+
+
+ {formatStatus(att.status)}
+
+
+
+ {att.notes || '-'}
+
+
+ ))
+ ) : (
+
+
+ Belum ada riwayat absensi.
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/pages/employee/index.tsx b/resources/js/pages/employee/index.tsx
index ca1f34b..0fd6e90 100644
--- a/resources/js/pages/employee/index.tsx
+++ b/resources/js/pages/employee/index.tsx
@@ -12,7 +12,7 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function EmployeeIndex() {
- const { auth } = usePage().props;
+ const { auth, flash } = usePage().props;
const user = auth.user;
return (
@@ -30,6 +30,18 @@ export default function EmployeeIndex() {
+ {/* Flash Messages */}
+ {flash?.error && (
+
+ {flash.error}
+
+ )}
+ {flash?.success && (
+
+ {flash.success}
+
+ )}
+
{/* Kartu Info Akun */}
diff --git a/routes/web.php b/routes/web.php
index 42576e4..b6e4654 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -23,6 +23,7 @@
Route::resource('positions', PositionController::class);
Route::resource('employees', EmployeeController::class);
Route::resource('payrolls', PayrollController::class)->only(['index', 'create', 'store', 'show']);
+ Route::get('attendances', [\App\Http\Controllers\Admin\AttendanceController::class, 'index'])->name('attendances.index');
Route::get('users', [UserController::class, 'index'])->name('users.index');
Route::get('users/create', [UserController::class, 'create'])->name('users.create');
@@ -33,8 +34,14 @@
// KELOMPOK KARYAWAN
Route::middleware(['auth', 'verified', 'role:employee'])->group(function () {
Route::get('/employee/index', function () {
- return Inertia::render('employee/index');
+ return Inertia\Inertia::render('employee/index');
})->name('employee.index');
+
+ Route::get('/employee/attendances', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'index'])->name('employee.attendances.index');
+ Route::post('/employee/attendances/clock-in', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'clockIn'])->name('employee.attendances.clock-in');
+ Route::post('/employee/attendances/clock-out', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'clockOut'])->name('employee.attendances.clock-out');
+ Route::post('/employee/attendances/leave', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'submitLeave'])->name('employee.attendances.leave');
+ Route::post('/employee/attendances/dispensation', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'submitDispensation'])->name('employee.attendances.dispensation');
});
require __DIR__.'/settings.php';
\ No newline at end of file