feat: implement attendance tracking system with admin and employee dashboards
This commit is contained in:
parent
859dbe13a1
commit
a3ad24e029
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Attendance;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$attendances = Attendance::with('employee')->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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Employee;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Attendance;
|
||||
use Illuminate\Http\Request;
|
||||
use Carbon\Carbon;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class EmployeeAttendanceController extends Controller
|
||||
{
|
||||
private function checkEmployee(Request $request)
|
||||
{
|
||||
return $request->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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Attendance extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'employee_id',
|
||||
'date',
|
||||
'check_in',
|
||||
'check_out',
|
||||
'latitude_in',
|
||||
'longitude_in',
|
||||
'latitude_out',
|
||||
'longitude_out',
|
||||
'status',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function employee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Employee::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('attendances', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
@ -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 <span className="text-green-600 font-medium">Hadir</span>;
|
||||
case 'leave': return <span className="text-yellow-600 font-medium">Izin/Cuti</span>;
|
||||
case 'dispensation': return <span className="text-blue-600 font-medium">Dispensasi</span>;
|
||||
default: return <span>{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Rekap Absensi" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Rekap Absensi Karyawan</h2>
|
||||
<p className="text-muted-foreground">Monitoring kehadiran, izin, dan dispensasi karyawan.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle>Daftar Absensi</CardTitle>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-0">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nama</TableHead>
|
||||
<TableHead>NIK</TableHead>
|
||||
<TableHead>Tanggal</TableHead>
|
||||
<TableHead>Jam Masuk</TableHead>
|
||||
<TableHead>Jam Keluar</TableHead>
|
||||
<TableHead>Keterangan</TableHead>
|
||||
<TableHead className="text-right">Total Jam Kerja</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{attendances.length > 0 ? (
|
||||
attendances.map((attendance) => (
|
||||
<TableRow key={attendance.id}>
|
||||
<TableCell className="font-medium text-gray-900">{attendance.employee_name}</TableCell>
|
||||
<TableCell className="text-gray-600">{attendance.employee_nik}</TableCell>
|
||||
<TableCell className="text-gray-600">{attendance.date}</TableCell>
|
||||
<TableCell>{attendance.check_in || '-'}</TableCell>
|
||||
<TableCell>{attendance.check_out || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
{formatStatus(attendance.status)}
|
||||
{attendance.notes && (
|
||||
<span className="text-xs text-gray-500 mt-1">{attendance.notes}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{attendance.total_hours ? `${attendance.total_hours} Jam` : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
||||
Belum ada data absensi.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<any>().props as SharedData;
|
||||
const [locationError, setLocationError] = useState<string | null>(null);
|
||||
const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [isLoadingLocation, setIsLoadingLocation] = useState<boolean>(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 (
|
||||
<AppLayout>
|
||||
<Head title="Absensi Karyawan" />
|
||||
|
||||
<div className="p-4 md:p-8 max-w-5xl mx-auto space-y-6">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Portal Absensi</h2>
|
||||
<p className="text-muted-foreground">Lakukan absen masuk/pulang, atau ajukan izin dan dispensasi.</p>
|
||||
</div>
|
||||
|
||||
{/* Flash Messages */}
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
{flash?.error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
|
||||
{flash.error}
|
||||
</div>
|
||||
)}
|
||||
{locationError && (
|
||||
<div className="p-4 bg-yellow-50 text-yellow-700 border border-yellow-200 rounded-md text-sm">
|
||||
{locationError} <Button variant="link" className="p-0 h-auto font-bold text-yellow-800" onClick={() => getLocation()}>Coba Lagi</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left Column: Forms */}
|
||||
<div className="md:col-span-1 space-y-6">
|
||||
<Tabs defaultValue="reguler" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-3">
|
||||
<TabsTrigger value="reguler">Harian</TabsTrigger>
|
||||
<TabsTrigger value="izin">Izin</TabsTrigger>
|
||||
<TabsTrigger value="dispen">Dispen</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab Absen Reguler */}
|
||||
<TabsContent value="reguler">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Absen Reguler</CardTitle>
|
||||
<CardDescription>
|
||||
{todayAttendance
|
||||
? 'Anda sudah memiliki catatan absensi hari ini.'
|
||||
: 'Sistem membutuhkan akses lokasi untuk mencatat absensi.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-zinc-50 border rounded-lg text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Lokasi Saat Ini:</p>
|
||||
<p className="font-mono text-xs font-semibold">
|
||||
{isLoadingLocation ? 'Mengambil lokasi...' : (coordinates ? `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}` : 'Lokasi tidak tersedia')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={handleClockIn}
|
||||
disabled={processingReguler || (todayAttendance && todayAttendance.check_in !== null)}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Clock In
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClockOut}
|
||||
disabled={processingReguler || !todayAttendance || (todayAttendance && todayAttendance.check_out !== null)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Clock Out
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Izin */}
|
||||
<TabsContent value="izin">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form Izin / Cuti</CardTitle>
|
||||
<CardDescription>Ajukan izin tidak masuk kerja.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLeaveSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date">Tanggal Izin</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
required
|
||||
value={leaveData.date}
|
||||
onChange={e => setLeaveData('date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave_notes">Keterangan (Sakit/Cuti/Dll)</Label>
|
||||
<Textarea
|
||||
id="leave_notes"
|
||||
placeholder="Tulis alasan izin..."
|
||||
required
|
||||
value={leaveData.notes}
|
||||
onChange={e => setLeaveData('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={processingLeave} className="w-full">
|
||||
Submit Izin
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Dispensasi */}
|
||||
<TabsContent value="dispen">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form Dispensasi</CardTitle>
|
||||
<CardDescription>Dispensasi tugas luar. Memerlukan lokasi.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleDispenSubmit} className="space-y-4">
|
||||
<div className="p-3 bg-zinc-50 border rounded-lg text-center mb-4">
|
||||
<p className="text-xs text-muted-foreground">Lokasi Tercatat:</p>
|
||||
<p className="font-mono text-xs font-semibold">
|
||||
{isLoadingLocation ? 'Mengambil lokasi...' : (coordinates ? `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}` : 'Lokasi tidak tersedia')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dispen_notes">Tujuan / Keterangan</Label>
|
||||
<Textarea
|
||||
id="dispen_notes"
|
||||
placeholder="Contoh: Meeting dengan klien X di lokasi Y"
|
||||
required
|
||||
value={dispenData.notes}
|
||||
onChange={e => setDispenData('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={processingDispen} className="w-full">
|
||||
Submit Dispensasi
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Right Column: History */}
|
||||
<div className="md:col-span-2">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Riwayat Absensi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative w-full overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tanggal</TableHead>
|
||||
<TableHead>Jam Masuk</TableHead>
|
||||
<TableHead>Jam Keluar</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Keterangan</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{attendances.length > 0 ? (
|
||||
attendances.map((att) => (
|
||||
<TableRow key={att.id}>
|
||||
<TableCell>{att.date}</TableCell>
|
||||
<TableCell>{att.check_in || '-'}</TableCell>
|
||||
<TableCell>{att.check_out || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-block px-2 py-1 text-xs font-medium bg-gray-100 rounded">
|
||||
{formatStatus(att.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{att.notes || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||||
Belum ada riwayat absensi.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
];
|
||||
|
||||
export default function EmployeeIndex() {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const { auth, flash } = usePage<any>().props;
|
||||
const user = auth.user;
|
||||
|
||||
return (
|
||||
|
|
@ -30,6 +30,18 @@ export default function EmployeeIndex() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Flash Messages */}
|
||||
{flash?.error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
|
||||
{flash.error}
|
||||
</div>
|
||||
)}
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kartu Info Akun */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
Loading…
Reference in New Issue