From cd50b5b10df3efb409ed230cb8e84cc6180fc1f9 Mon Sep 17 00:00:00 2001 From: IlhamIslamy Date: Tue, 14 Apr 2026 23:53:38 +0700 Subject: [PATCH] Feat: Payroll feature with refactor UI Management --- .../Controllers/Admin/PayrollController.php | 129 ++++++ .../Controllers/Admin/PositionController.php | 6 +- app/Http/Controllers/Admin/UserController.php | 165 ++++---- app/Models/Payroll.php | 34 ++ app/Models/Position.php | 6 +- app/Models/User.php | 7 + ...01_add_basic_salary_to_positions_table.php | 28 ++ ...026_04_14_000002_create_payrolls_table.php | 33 ++ ..._000003_add_employee_id_to_users_table.php | 32 ++ database/seeders/DatabaseSeeder.php | 6 +- database/seeders/EmployeeSeeder.php | 279 +++++++++++++ database/seeders/MasterDataSeeder.php | 45 ++- database/seeders/PayrollSeeder.php | 324 +++++++++++++++ resources/js/components/app-sidebar.tsx | 7 +- resources/js/components/ui/table.tsx | 102 +++++ .../js/pages/admin/departments/index.tsx | 24 +- resources/js/pages/admin/employees/index.tsx | 10 +- resources/js/pages/admin/payrolls/create.tsx | 374 ++++++++++++++++++ resources/js/pages/admin/payrolls/index.tsx | 150 +++++++ resources/js/pages/admin/payrolls/show.tsx | 290 ++++++++++++++ resources/js/pages/admin/positions/create.tsx | 48 ++- resources/js/pages/admin/positions/edit.tsx | 47 ++- resources/js/pages/admin/positions/index.tsx | 24 +- resources/js/pages/admin/users/create.tsx | 226 +++++------ resources/js/pages/admin/users/index.tsx | 22 +- resources/js/pages/auth/login.tsx | 185 +++++---- routes/web.php | 11 +- 27 files changed, 2272 insertions(+), 342 deletions(-) create mode 100644 app/Http/Controllers/Admin/PayrollController.php create mode 100644 app/Models/Payroll.php create mode 100644 database/migrations/2026_04_14_000001_add_basic_salary_to_positions_table.php create mode 100644 database/migrations/2026_04_14_000002_create_payrolls_table.php create mode 100644 database/migrations/2026_04_14_000003_add_employee_id_to_users_table.php create mode 100644 database/seeders/EmployeeSeeder.php create mode 100644 database/seeders/PayrollSeeder.php create mode 100644 resources/js/components/ui/table.tsx create mode 100644 resources/js/pages/admin/payrolls/create.tsx create mode 100644 resources/js/pages/admin/payrolls/index.tsx create mode 100644 resources/js/pages/admin/payrolls/show.tsx diff --git a/app/Http/Controllers/Admin/PayrollController.php b/app/Http/Controllers/Admin/PayrollController.php new file mode 100644 index 0000000..2ebd628 --- /dev/null +++ b/app/Http/Controllers/Admin/PayrollController.php @@ -0,0 +1,129 @@ +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, + ]); + } + + /** + * Tampilkan form Generate Payroll. + * Kirim daftar employees (beserta relasi position.basic_salary) ke frontend. + */ + public function create() + { + $employees = Employee::with(['position', 'department']) + ->orderBy('name') + ->get() + ->map(fn($e) => [ + 'id' => $e->id, + 'name' => $e->name, + 'nip' => $e->nip, + 'position' => [ + 'id' => $e->position?->id, + 'name' => $e->position?->name, + 'basic_salary' => $e->position?->basic_salary ?? 0, + ], + 'department' => [ + 'name' => $e->department?->name, + ], + ]); + + return Inertia::render('admin/payrolls/create', [ + 'employees' => $employees, + ]); + } + + /** + * Simpan data payroll & hitung net_salary secara otomatis. + * + * Logika kalkulasi: + * net_salary = basic_salary + * + SUM(detail tipe "bonus") + * - SUM(detail tipe "deduction") + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'period' => 'required|string|max:7', + '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', + ]); + + $net = $validated['basic_salary']; + foreach ($validated['details'] ?? [] as $item) { + $net += $item['type'] === 'bonus' + ? $item['amount'] + : -$item['amount']; + } + $net = max(0, $net); + + Payroll::create([ + 'employee_id' => $validated['employee_id'], + 'period' => $validated['period'], + 'basic_salary' => $validated['basic_salary'], + 'details' => $validated['details'] ?? [], + 'net_salary' => $net, + 'status' => 'pending', + ]); + + return redirect('/admin/payrolls') + ->with('success', 'Payroll berhasil di-generate.'); + } + + /** + * Tampilkan detail slip gaji 1 payroll. + */ + public function show(Payroll $payroll) + { + $payroll->load('employee.position', 'employee.department'); + + return Inertia::render('admin/payrolls/show', [ + 'payroll' => [ + 'id' => $payroll->id, + 'period' => $payroll->period, + 'basic_salary' => $payroll->basic_salary, + 'details' => $payroll->details ?? [], + 'net_salary' => $payroll->net_salary, + 'status' => $payroll->status, + 'created_at' => $payroll->created_at->format('d F Y'), + 'employee' => [ + 'name' => $payroll->employee?->name, + 'nip' => $payroll->employee?->nip, + 'position' => $payroll->employee?->position?->name, + 'department' => $payroll->employee?->department?->name, + ], + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/PositionController.php b/app/Http/Controllers/Admin/PositionController.php index 3b73216..1b22ab7 100644 --- a/app/Http/Controllers/Admin/PositionController.php +++ b/app/Http/Controllers/Admin/PositionController.php @@ -24,7 +24,8 @@ public function create() public function store(Request $request) { $validated = $request->validate([ - 'name' => 'required|string|max:255|unique:positions,name', + 'name' => 'required|string|max:255|unique:positions,name', + 'basic_salary' => 'nullable|integer|min:0', ]); Position::create($validated); @@ -43,7 +44,8 @@ public function edit(Position $position) public function update(Request $request, Position $position) { $validated = $request->validate([ - 'name' => 'required|string|max:255|unique:positions,name,' . $position->id, + 'name' => 'required|string|max:255|unique:positions,name,' . $position->id, + 'basic_salary' => 'nullable|integer|min:0', ]); $position->update($validated); diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 8a8955d..928ba10 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -1,68 +1,97 @@ - User::select('id', 'name', 'email', 'role', 'created_at') - ->latest() - ->get(), - ]); - } - - public function create() - { - return Inertia::render('admin/users/create'); - } - - public function store(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|email|max:255|unique:users,email', - 'password' => 'required|string|min:8|confirmed', - 'role' => ['required', Rule::in(['admin', 'employee'])], - ], [ - 'name.required' => 'Nama wajib diisi.', - '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.', - ]); - - User::create([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'password' => Hash::make($validated['password']), - 'role' => $validated['role'], - ]); - - return redirect()->route('admin.users.index') - ->with('success', "User {$validated['name']} berhasil ditambahkan."); - } - - public function update(Request $request, User $user) - { - $validated = $request->validate([ - 'role' => ['required', Rule::in(['admin', 'employee'])], - ]); - - $user->update($validated); - - return back()->with('success', "Role {$user->name} berhasil diubah."); - } -} \ No newline at end of file + User::select('id', 'name', 'email', 'role', 'created_at') + ->latest() + ->get(), + ]); + } + + /** + * Kirim hanya karyawan yang BELUM memiliki akun user ke form create. + * Query whereDoesntHave('user') mencegah 1 employee punya 2 akun. + */ + public function create() + { + $employees = Employee::whereDoesntHave('user') + ->orderBy('name') + ->get() + ->map(fn($e) => [ + 'id' => $e->id, + 'name' => $e->name, + 'nip' => $e->nip, + 'position' => $e->position?->name ?? '-', + ]); + + return Inertia::render('admin/users/create', [ + 'employees' => $employees, + ]); + } + + /** + * Buat akun user baru yang terhubung ke Employee yang dipilih. + * - Nama diambil otomatis dari Employee. + * - Password di-set default 'password'. + * - employee_id disimpan di kolom users.employee_id. + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'employee_id' => [ + 'required', + 'exists:employees,id', + 'unique:users,employee_id', // pastikan belum punya user + ], + 'email' => 'required|email|max:255|unique:users,email', + 'role' => ['required', Rule::in(['admin', 'employee'])], + ], [ + 'employee_id.required' => 'Karyawan wajib dipilih.', + 'employee_id.exists' => 'Karyawan tidak ditemukan.', + 'employee_id.unique' => 'Karyawan ini sudah memiliki akun.', + 'email.required' => 'Email wajib diisi.', + 'email.email' => 'Format email tidak valid.', + 'email.unique' => 'Email sudah terdaftar.', + 'role.required' => 'Role wajib dipilih.', + 'role.in' => 'Role hanya boleh admin atau employee.', + ]); + + // Ambil nama dari profil Employee yang dipilih + $employee = Employee::findOrFail($validated['employee_id']); + + User::create([ + 'name' => $employee->name, + 'email' => $validated['email'], + 'password' => Hash::make('password'), // default password + 'role' => $validated['role'], + 'employee_id' => $employee->id, + ]); + + return redirect()->route('admin.users.index') + ->with('success', "Akun untuk {$employee->name} berhasil dibuat. Password default: password"); + } + + public function update(Request $request, User $user) + { + $validated = $request->validate([ + 'role' => ['required', Rule::in(['admin', 'employee'])], + ]); + + $user->update($validated); + + return back()->with('success', "Role {$user->name} berhasil diubah."); + } +} \ No newline at end of file diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php new file mode 100644 index 0000000..dca344b --- /dev/null +++ b/app/Models/Payroll.php @@ -0,0 +1,34 @@ + 'array', + 'basic_salary' => 'integer', + 'net_salary' => 'integer', + ]; + + /** + * Relasi ke Employee + */ + public function employee() + { + return $this->belongsTo(Employee::class); + } +} diff --git a/app/Models/Position.php b/app/Models/Position.php index aaffc96..7c52116 100644 --- a/app/Models/Position.php +++ b/app/Models/Position.php @@ -9,7 +9,11 @@ class Position extends Model { use HasFactory; - protected $fillable = ['name']; + protected $fillable = ['name', 'basic_salary']; + + protected $casts = [ + 'basic_salary' => 'integer', + ]; public function employees() { diff --git a/app/Models/User.php b/app/Models/User.php index 25c227f..376be0e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -23,6 +23,7 @@ class User extends Authenticatable 'email', 'password', 'role', + 'employee_id', ]; /** @@ -42,6 +43,12 @@ public function employee() return $this->hasOne(Employee::class); } + /** Akses langsung ke profil Employee milik user ini */ + public function employeeProfile() + { + return $this->belongsTo(Employee::class, 'employee_id'); + } + public function hasRole($role) { return $this->role === $role; diff --git a/database/migrations/2026_04_14_000001_add_basic_salary_to_positions_table.php b/database/migrations/2026_04_14_000001_add_basic_salary_to_positions_table.php new file mode 100644 index 0000000..d7c4a6d --- /dev/null +++ b/database/migrations/2026_04_14_000001_add_basic_salary_to_positions_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('basic_salary')->default(0)->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('positions', function (Blueprint $table) { + $table->dropColumn('basic_salary'); + }); + } +}; diff --git a/database/migrations/2026_04_14_000002_create_payrolls_table.php b/database/migrations/2026_04_14_000002_create_payrolls_table.php new file mode 100644 index 0000000..af24910 --- /dev/null +++ b/database/migrations/2026_04_14_000002_create_payrolls_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('employee_id')->constrained()->onDelete('cascade'); + $table->string('period'); // Format: "2026-04" (YYYY-MM) + $table->unsignedBigInteger('basic_salary'); + $table->json('details')->nullable(); // [{name, type, amount}] + $table->unsignedBigInteger('net_salary'); + $table->enum('status', ['pending', 'paid'])->default('pending'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payrolls'); + } +}; diff --git a/database/migrations/2026_04_14_000003_add_employee_id_to_users_table.php b/database/migrations/2026_04_14_000003_add_employee_id_to_users_table.php new file mode 100644 index 0000000..2dc2440 --- /dev/null +++ b/database/migrations/2026_04_14_000003_add_employee_id_to_users_table.php @@ -0,0 +1,32 @@ +foreignId('employee_id') + ->nullable() + ->unique() // One-to-One: 1 user = 1 employee + ->constrained('employees') + ->nullOnDelete() // Jika employee dihapus, kolom ini jadi NULL + ->after('id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['employee_id']); + $table->dropColumn('employee_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ff4e9eb..d064134 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -9,8 +9,10 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ - UserSeeder::class, - MasterDataSeeder::class, + UserSeeder::class, // Admin account (jangan dihapus, dibutuhkan login) + MasterDataSeeder::class, // 1. Departments + Positions (dengan basic_salary) + EmployeeSeeder::class, // 2. Users (employee) + profil Employee (FK ke dept & position) + PayrollSeeder::class, // 3. Slip gaji (FK ke employee) ]); } } \ No newline at end of file diff --git a/database/seeders/EmployeeSeeder.php b/database/seeders/EmployeeSeeder.php new file mode 100644 index 0000000..2b5dd38 --- /dev/null +++ b/database/seeders/EmployeeSeeder.php @@ -0,0 +1,279 @@ + [ + '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' => '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', + ], + ], + + // ── HR DEPARTMENT ──────────────────────────────────────── + + [ + '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', + ], + ], + + // ── FINANCE DEPARTMENT ─────────────────────────────────── + + [ + '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', + ], + ], + + // ── MARKETING DEPARTMENT ───────────────────────────────── + + [ + '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', + ], + ], + + // ── OPERATIONS DEPARTMENT ──────────────────────────────── + + [ + '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', + ], + ], + ]; + + // ────────────────────────────────────────────────────────────── + // Buat User → lalu buat Employee yang terhubung ke user tersebut + // ────────────────────────────────────────────────────────────── + foreach ($employees as $data) { + $user = User::create($data['user']); + Employee::create(array_merge($data['employee'], ['user_id' => $user->id])); + } + + $this->command->info('EmployeeSeeder: ' . count($employees) . ' karyawan berhasil dibuat.'); + } +} diff --git a/database/seeders/MasterDataSeeder.php b/database/seeders/MasterDataSeeder.php index 156848a..6624451 100644 --- a/database/seeders/MasterDataSeeder.php +++ b/database/seeders/MasterDataSeeder.php @@ -6,38 +6,49 @@ use App\Models\Position; use Illuminate\Database\Seeder; +/** + * MasterDataSeeder + * + * Seed departemen dan jabatan beserta gaji pokok masing-masing. + * Seeder ini HARUS dijalankan sebelum EmployeeSeeder dan PayrollSeeder. + */ class MasterDataSeeder extends Seeder { public function run(): void { - // === DEPARTMENTS === - $depts = [ + // ────────────────────────────────────────────── + // DEPARTMENTS + // ────────────────────────────────────────────── + $departments = [ ['name' => 'Information Technology', 'description' => 'Bagian IT & Development'], - ['name' => 'Human Resources', 'description' => 'Bagian Kepegawaian'], - ['name' => 'Finance', 'description' => 'Bagian Keuangan & Akuntansi'], - ['name' => 'Marketing', 'description' => 'Bagian Pemasaran & Komunikasi'], - ['name' => 'Operations', 'description' => 'Bagian Operasional & Logistik'], + ['name' => 'Human Resources', 'description' => 'Bagian Kepegawaian'], + ['name' => 'Finance', 'description' => 'Bagian Keuangan & Akuntansi'], + ['name' => 'Marketing', 'description' => 'Bagian Pemasaran & Komunikasi'], + ['name' => 'Operations', 'description' => 'Bagian Operasional & Logistik'], ]; - foreach ($depts as $dept) { + foreach ($departments as $dept) { Department::create($dept); } - // === POSITIONS === + // ────────────────────────────────────────────── + // POSITIONS + GAJI POKOK + // ────────────────────────────────────────────── $positions = [ - 'Manager', - 'Senior Developer', - 'Junior Developer', - 'Staff Admin', - 'HR Specialist', - 'Finance Analyst', - 'Marketing Staff', - 'Operations Staff', + ['name' => 'Manager', 'basic_salary' => 12_000_000], + ['name' => 'Senior Developer', 'basic_salary' => 9_000_000], + ['name' => 'Junior Developer', 'basic_salary' => 5_500_000], + ['name' => 'Staff Admin', 'basic_salary' => 4_000_000], + ['name' => 'HR Specialist', 'basic_salary' => 5_800_000], + ['name' => 'Finance Analyst', 'basic_salary' => 7_000_000], + ['name' => 'Marketing Staff', 'basic_salary' => 4_500_000], + ['name' => 'Operations Staff', 'basic_salary' => 4_200_000], ]; foreach ($positions as $pos) { - Position::create(['name' => $pos]); + Position::create($pos); } + $this->command->info('MasterDataSeeder: Departments & Positions berhasil dibuat.'); } } \ No newline at end of file diff --git a/database/seeders/PayrollSeeder.php b/database/seeders/PayrollSeeder.php new file mode 100644 index 0000000..be918ff --- /dev/null +++ b/database/seeders/PayrollSeeder.php @@ -0,0 +1,324 @@ +subMonth()->format('Y-m'); + $bulanIni = now()->format('Y-m'); + + // ────────────────────────────────────────────────────────────── + // DATASET PAYROLL + // ────────────────────────────────────────────────────────────── + $records = []; + + // ── 1. JONO JONI — Senior Developer (IT) ───────────────────── + // Kasus: Karyawan senior, ada bonus proyek + tunjangan lengkap. + // Bulan lalu: sudah dibayar. Bulan ini: masih pending. + if (isset($emp['Jono Joni'])) { + $basicJono = 9_000_000; + + $detailLalu = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 600_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 450_000], + ['name' => 'Potongan BPJS Kesehatan','type' => 'deduction', 'amount' => 180_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 90_000], + ]; + $records[] = [ + 'employee_id' => $emp['Jono Joni'], + 'period' => $bulanLalu, + 'basic_salary' => $basicJono, + 'details' => $detailLalu, + 'net_salary' => $net($basicJono, $detailLalu), + 'status' => 'paid', + ]; + + $detailIni = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 600_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 450_000], + ['name' => 'Bonus Proyek Klien', 'type' => 'bonus', 'amount' => 1_500_000], + ['name' => 'Potongan BPJS Kesehatan','type' => 'deduction', 'amount' => 180_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 90_000], + ]; + $records[] = [ + 'employee_id' => $emp['Jono Joni'], + 'period' => $bulanIni, + 'basic_salary' => $basicJono, + 'details' => $detailIni, + 'net_salary' => $net($basicJono, $detailIni), + 'status' => 'pending', + ]; + } + + // ── 2. BUDI SANTOSO — Junior Developer (IT) ────────────────── + // Kasus: Karyawan kontrak, ada potongan keterlambatan. + if (isset($emp['Budi Santoso'])) { + $basicBudi = 5_500_000; + + $detailLalu = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 400_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 350_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 55_000], + ['name' => 'Potongan Keterlambatan', 'type' => 'deduction', 'amount' => 100_000], + ]; + $records[] = [ + 'employee_id' => $emp['Budi Santoso'], + 'period' => $bulanLalu, + 'basic_salary' => $basicBudi, + 'details' => $detailLalu, + 'net_salary' => $net($basicBudi, $detailLalu), + 'status' => 'paid', + ]; + + $detailIni = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 400_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 350_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 55_000], + ]; + $records[] = [ + 'employee_id' => $emp['Budi Santoso'], + 'period' => $bulanIni, + 'basic_salary' => $basicBudi, + 'details' => $detailIni, + 'net_salary' => $net($basicBudi, $detailIni), + 'status' => 'pending', + ]; + } + + // ── 3. RIZKY FIRMANSYAH — Junior Developer Magang (IT) ─────── + // Kasus: Karyawan magang, komponen sangat minimal, tidak ada BPJS penuh. + if (isset($emp['Rizky Firmansyah'])) { + $basicRizky = 5_500_000; // gaji pokok jabatan "Junior Developer" + + $detailIni = [ + ['name' => 'Uang Transport Magang', 'type' => 'bonus', 'amount' => 200_000], + ['name' => 'Potongan Tidak Hadir', 'type' => 'deduction', 'amount' => 250_000], + ]; + $records[] = [ + 'employee_id' => $emp['Rizky Firmansyah'], + 'period' => $bulanIni, + 'basic_salary' => $basicRizky, + 'details' => $detailIni, + 'net_salary' => $net($basicRizky, $detailIni), + 'status' => 'pending', + ]; + } + + // ── 4. DEWI RAHAYU — Manager HR ────────────────────────────── + // Kasus: Manajer dengan tunjangan jabatan besar + BPJS penuh. + if (isset($emp['Dewi Rahayu'])) { + $basicDewi = 12_000_000; + + $detailLalu = [ + ['name' => 'Tunjangan Jabatan', 'type' => 'bonus', 'amount' => 2_000_000], + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 700_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 500_000], + ['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 240_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 120_000], + ['name' => 'Potongan PPh 21', 'type' => 'deduction', 'amount' => 500_000], + ]; + $records[] = [ + 'employee_id' => $emp['Dewi Rahayu'], + 'period' => $bulanLalu, + 'basic_salary' => $basicDewi, + 'details' => $detailLalu, + 'net_salary' => $net($basicDewi, $detailLalu), + 'status' => 'paid', + ]; + + $detailIni = [ + ['name' => 'Tunjangan Jabatan', 'type' => 'bonus', 'amount' => 2_000_000], + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 700_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 500_000], + ['name' => 'Bonus Kinerja Triwulan', 'type' => 'bonus', 'amount' => 1_000_000], + ['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 240_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 120_000], + ['name' => 'Potongan PPh 21', 'type' => 'deduction', 'amount' => 600_000], + ]; + $records[] = [ + 'employee_id' => $emp['Dewi Rahayu'], + 'period' => $bulanIni, + 'basic_salary' => $basicDewi, + 'details' => $detailIni, + 'net_salary' => $net($basicDewi, $detailIni), + 'status' => 'pending', + ]; + } + + // ── 5. ANISA PUTRI — HR Specialist ─────────────────────────── + // Kasus: Karyawan tetap biasa, komponen standard. + if (isset($emp['Anisa Putri'])) { + $basicAnisa = 5_800_000; + + $detailLalu = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 400_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 350_000], + ['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 116_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 58_000], + ]; + $records[] = [ + 'employee_id' => $emp['Anisa Putri'], + 'period' => $bulanLalu, + 'basic_salary' => $basicAnisa, + 'details' => $detailLalu, + 'net_salary' => $net($basicAnisa, $detailLalu), + 'status' => 'paid', + ]; + } + + // ── 6. HENDRA KURNIAWAN — Finance Analyst ──────────────────── + // Kasus: Karyawan lama, ada potongan cicilan pinjaman perusahaan. + if (isset($emp['Hendra Kurniawan'])) { + $basicHendra = 7_000_000; + + $detailLalu = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 500_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 450_000], + ['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 140_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 70_000], + ['name' => 'Cicilan Pinjaman', 'type' => 'deduction', 'amount' => 500_000], + ]; + $records[] = [ + 'employee_id' => $emp['Hendra Kurniawan'], + 'period' => $bulanLalu, + 'basic_salary' => $basicHendra, + 'details' => $detailLalu, + 'net_salary' => $net($basicHendra, $detailLalu), + 'status' => 'paid', + ]; + + $detailIni = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 500_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 450_000], + ['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 140_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 70_000], + ['name' => 'Cicilan Pinjaman', 'type' => 'deduction', 'amount' => 500_000], + ]; + $records[] = [ + 'employee_id' => $emp['Hendra Kurniawan'], + 'period' => $bulanIni, + 'basic_salary' => $basicHendra, + 'details' => $detailIni, + 'net_salary' => $net($basicHendra, $detailIni), + 'status' => 'pending', + ]; + } + + // ── 7. SARI WULANDARI — Staff Admin Finance (PKWT) ─────────── + // Kasus: Kontrak, ada potongan absen, tidak ada bonus. + if (isset($emp['Sari Wulandari'])) { + $basicSari = 4_000_000; + + $detailIni = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 300_000], + ['name' => 'Potongan Absen', 'type' => 'deduction', 'amount' => 200_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 40_000], + ]; + $records[] = [ + 'employee_id' => $emp['Sari Wulandari'], + 'period' => $bulanIni, + 'basic_salary' => $basicSari, + 'details' => $detailIni, + 'net_salary' => $net($basicSari, $detailIni), + 'status' => 'pending', + ]; + } + + // ── 8. FAJAR NUGROHO — Marketing Staff ─────────────────────── + // Kasus: Marketing, ada bonus komisi penjualan bulan lalu. + if (isset($emp['Fajar Nugroho'])) { + $basicFajar = 4_500_000; + + $detailLalu = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 350_000], + ['name' => 'Komisi Penjualan', 'type' => 'bonus', 'amount' => 2_000_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 45_000], + ]; + $records[] = [ + 'employee_id' => $emp['Fajar Nugroho'], + 'period' => $bulanLalu, + 'basic_salary' => $basicFajar, + 'details' => $detailLalu, + 'net_salary' => $net($basicFajar, $detailLalu), + 'status' => 'paid', + ]; + + $detailIni = [ + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 350_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 45_000], + ]; + $records[] = [ + 'employee_id' => $emp['Fajar Nugroho'], + 'period' => $bulanIni, + 'basic_salary' => $basicFajar, + 'details' => $detailIni, + 'net_salary' => $net($basicFajar, $detailIni), + 'status' => 'pending', + ]; + } + + // ── 9. AGUS PRASETYO — Manager Operations ──────────────────── + // Kasus: Manager senior dengan PPh 21 dan semua tunjangan. + if (isset($emp['Agus Prasetyo'])) { + $basicAgus = 12_000_000; + + $detailLalu = [ + ['name' => 'Tunjangan Jabatan', 'type' => 'bonus', 'amount' => 2_500_000], + ['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 700_000], + ['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 500_000], + ['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 240_000], + ['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 120_000], + ['name' => 'Potongan PPh 21', 'type' => 'deduction', 'amount' => 650_000], + ]; + $records[] = [ + 'employee_id' => $emp['Agus Prasetyo'], + 'period' => $bulanLalu, + 'basic_salary' => $basicAgus, + 'details' => $detailLalu, + 'net_salary' => $net($basicAgus, $detailLalu), + 'status' => 'paid', + ]; + } + + // ────────────────────────────────────────────────────────────── + // Insert semua record + // ────────────────────────────────────────────────────────────── + foreach ($records as $data) { + Payroll::create($data); + } + + $this->command->info('PayrollSeeder: ' . count($records) . ' slip gaji berhasil dibuat.'); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index af459e0..3c18048 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 } from 'lucide-react'; +import { LayoutGrid, Users, Briefcase, Building2, SquareUser, Wallet } from 'lucide-react'; import { NavMain } from '@/components/nav-main'; import { NavUser } from '@/components/nav-user'; import { @@ -42,6 +42,11 @@ export function AppSidebar() { href: '/admin/positions', icon: Briefcase, }, + { + title: 'Manajemen Gaji', + href: '/admin/payrolls', + icon: Wallet, + }, { title: 'Manajemen Pengguna', href: '/admin/users', diff --git a/resources/js/components/ui/table.tsx b/resources/js/components/ui/table.tsx new file mode 100644 index 0000000..d2f4927 --- /dev/null +++ b/resources/js/components/ui/table.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', className)} + {...props} + /> +)); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> +)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> +)); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = 'TableCaption'; + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/resources/js/pages/admin/departments/index.tsx b/resources/js/pages/admin/departments/index.tsx index 95a7ef7..ff15689 100644 --- a/resources/js/pages/admin/departments/index.tsx +++ b/resources/js/pages/admin/departments/index.tsx @@ -39,7 +39,14 @@ export default function Index({ departments }: PageProps) {
- + + {/* Page Header */} +
+

Data Departemen

+

Kelola struktur organisasi dan unit kerja.

+
+ + {/* Flash Messages */} {flash?.success && (
{flash.success} @@ -51,19 +58,12 @@ export default function Index({ departments }: PageProps) {
)} -
-
-

Data Departemen

-

Kelola struktur organisasi dan unit kerja.

-
- -
- - + Daftar Departemen + diff --git a/resources/js/pages/admin/employees/index.tsx b/resources/js/pages/admin/employees/index.tsx index a910564..45d79c3 100644 --- a/resources/js/pages/admin/employees/index.tsx +++ b/resources/js/pages/admin/employees/index.tsx @@ -77,7 +77,15 @@ export default function Index({ employees, departments, filters }: PageProps) { return ( -
+
+ + {/* Page Header */} +
+

Manajemen Karyawan

+

Kelola data seluruh karyawan perusahaan.

+
+ + {/* Flash Messages */} {flash?.success && (
{flash.success} diff --git a/resources/js/pages/admin/payrolls/create.tsx b/resources/js/pages/admin/payrolls/create.tsx new file mode 100644 index 0000000..2f1c370 --- /dev/null +++ b/resources/js/pages/admin/payrolls/create.tsx @@ -0,0 +1,374 @@ +import { Head, useForm, Link } from '@inertiajs/react'; +import React, { useState } 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'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface Position { + id: number; + name: string; + basic_salary: number; +} + +interface Department { + name: string; +} + +interface Employee { + id: number; + name: string; + nip: string; + position: Position; + department: Department; +} + +interface DetailItem { + name: string; + type: 'bonus' | 'deduction'; + amount: number; +} + +interface PageProps { + employees: Employee[]; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatRupiah(value: number): string { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(value); +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export default function Create({ employees }: PageProps) { + const { data, setData, post, processing, errors } = useForm<{ + employee_id: string; + period: string; + basic_salary: number; + details: DetailItem[]; + }>({ + employee_id: '', + period: '', + basic_salary: 0, + details: [], + }); + + const [selectedEmployee, setSelectedEmployee] = useState(null); + + // Kalkulasi net salary secara real-time di sisi frontend + 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 + const handleEmployeeChange = (value: string) => { + const emp = employees.find((e) => String(e.id) === value) ?? null; + setSelectedEmployee(emp); + setData((prev) => ({ + ...prev, + employee_id: value, + basic_salary: emp?.position?.basic_salary ?? 0, + })); + }; + + // Tambah baris komponen (bonus/potongan) + const addDetail = () => { + setData('details', [ + ...data.details, + { name: '', type: 'bonus', amount: 0 }, + ]); + }; + + // Update baris komponen pada index tertentu + 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); + }; + + // Hapus baris komponen + const removeDetail = (index: number) => { + setData('details', data.details.filter((_, i) => i !== index)); + }; + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + post('/admin/payrolls'); + }; + + return ( + + + +
+ {/* Back button */} + + + + + Form Generate Payroll +

+ Pilih karyawan dan periode, lalu tambahkan komponen bonus/potongan jika diperlukan. +

+
+ + + + +
+ + {/* ── SEKSI 1: Informasi Utama ── */} +
+

+ + 1 + + Informasi Utama +

+ +
+ {/* Pilih Karyawan */} +
+ + + {errors.employee_id && ( +

{errors.employee_id}

+ )} +
+ + {/* Periode */} +
+ + setData('period', e.target.value)} + /> + {errors.period && ( +

{errors.period}

+ )} +
+
+ + {/* Info Karyawan Terpilih */} + {selectedEmployee && ( +
+
+
+ Departemen:{' '} + + {selectedEmployee.department.name} + +
+
+ Jabatan:{' '} + + {selectedEmployee.position.name} + +
+
+
+ )} +
+ + + + {/* ── SEKSI 2: Gaji Pokok (Auto-fill) ── */} +
+

+ + 2 + + Gaji Pokok +

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

{errors.basic_salary}

+ )} +
+
+ + + + {/* ── SEKSI 3: Komponen Bonus / Potongan ── */} +
+
+

+ + 3 + + Komponen Gaji +

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

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

+ ) : ( +
+ {/* Header kolom */} +
+ Nama Komponen + Tipe + Nominal (Rp) + +
+ + {data.details.map((item, index) => ( +
+ {/* Nama Komponen */} + + updateDetail(index, 'name', e.target.value) + } + /> + + {/* Tipe */} + + + {/* Nominal */} + + updateDetail(index, 'amount', e.target.value) + } + /> + + {/* Hapus */} + +
+ ))} +
+ )} +
+ + + + {/* ── 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 new file mode 100644 index 0000000..dac78c0 --- /dev/null +++ b/resources/js/pages/admin/payrolls/index.tsx @@ -0,0 +1,150 @@ +import { Head, Link } 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import AppLayout from '@/layouts/app-layout'; +import { PlusCircle, FileText } from 'lucide-react'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface Payroll { + id: number; + employee_name: string; + employee_nip: string; + period: string; + net_salary: number; + status: 'pending' | 'paid'; +} + +interface PageProps { + payrolls: Payroll[]; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatRupiah(value: number): string { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(value); +} + +/** Format "2026-04" → "April 2026" */ +function formatPeriod(period: string): string { + const [year, month] = period.split('-'); + const date = new Date(Number(year), Number(month) - 1, 1); + return date.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' }); +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export default function Index({ payrolls }: PageProps) { + return ( + + + +
+ + {/* Page Header */} +
+

Manajemen Payroll

+

Daftar seluruh payroll yang telah di-generate.

+
+ + + + Daftar Slip Gaji + + + + + {payrolls.length === 0 ? ( +
+ +

+ Belum ada data payroll. +

+ +
+ ) : ( + + + + # + Karyawan + Periode + Gaji Bersih + Status + Aksi + + + + {payrolls.map((payroll, index) => ( + + + {index + 1} + + +
{payroll.employee_name}
+
+ NIP: {payroll.employee_nip} +
+
+ {formatPeriod(payroll.period)} + + {formatRupiah(payroll.net_salary)} + + + + + + + +
+ ))} +
+
+ )} +
+
+
+
+ ); +} + +// ─── Sub-component: Status Badge ───────────────────────────────────────────── + +function StatusBadge({ status }: { status: 'pending' | 'paid' }) { + if (status === 'paid') { + return ( + + Paid + + ); + } + return ( + + Pending + + ); +} diff --git a/resources/js/pages/admin/payrolls/show.tsx b/resources/js/pages/admin/payrolls/show.tsx new file mode 100644 index 0000000..ad174c2 --- /dev/null +++ b/resources/js/pages/admin/payrolls/show.tsx @@ -0,0 +1,290 @@ +import { Head, Link } from '@inertiajs/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'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +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'; + created_at: string; + employee: { + name: string; + nip: string; + position: string; + department: string; + }; +} + +interface PageProps { + payroll: PayrollData; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +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('-'); + const date = new Date(Number(year), Number(month) - 1, 1); + return date.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' }); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function InfoCell({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function LineItem({ + label, + value, + type = 'neutral', +}: { + label: string; + value: string; + type?: 'neutral' | 'bonus' | 'deduction'; +}) { + const valueClass = + type === 'bonus' + ? 'text-emerald-600 font-medium' + : type === 'deduction' + ? 'text-red-500 font-medium' + : 'font-medium'; + const prefix = type === 'bonus' ? '+ ' : type === 'deduction' ? '− ' : ''; + + return ( +
+ {label} + + {prefix}{value} + +
+ ); +} + +function StatusBadge({ status }: { status: 'pending' | 'paid' }) { + return status === 'paid' ? ( + + Lunas + + ) : ( + + Belum Dibayar + + ); +} + +// ─── 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); + + return ( + + + +
+ + {/* Toolbar — tersembunyi saat print */} +
+ + +
+ + {/* ── Slip Card ── */} + + + {/* ── HEADER ── */} +
+
+ {/* Nama Perusahaan */} +
+
+ +
+
+

+ PT. HRIS Nusantara +

+

+ Slip Gaji Karyawan +

+
+
+ {/* Meta slip */} +
+
+ + + {String(payroll.id).padStart(5, '0')} + +
+
+ + {payroll.created_at} +
+
+ +
+
+
+
+ + + + {/* ── INFO KARYAWAN ── */} +
+

+ Informasi Karyawan +

+
+ + + + +
+
+ + {/* ── PERIODE ── */} +
+

+ Periode Penggajian +

+

+ {formatPeriod(payroll.period)} +

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

+ Komponen Gaji +

+ + + {/* Bonus */} + {bonuses.length > 0 && ( + <> + +

+ Tambahan +

+ {bonuses.map((item, i) => ( + + ))} +
+ Subtotal tambahan + + + {formatRupiah(totalBonus)} + +
+ + )} + + {/* Potongan */} + {deductions.length > 0 && ( + <> + +

+ Potongan +

+ {deductions.map((item, i) => ( + + ))} +
+ Subtotal potongan + + − {formatRupiah(totalDeduction)} + +
+ + )} +
+ + {/* ── TOTAL GAJI BERSIH ── */} +
+
+
+

+ Total Gaji Bersih +

+

+ Gaji Pokok + {bonuses.length > 0 && ' + Tambahan'} + {deductions.length > 0 && ' − Potongan'} +

+
+

+ {formatRupiah(payroll.net_salary)} +

+
+
+ + {/* ── FOOTER ── */} +
+

+ Dokumen ini diterbitkan secara otomatis oleh sistem HRIS dan sah tanpa tanda tangan basah. +

+
+ +
+
+ +
+
+ ); +} diff --git a/resources/js/pages/admin/positions/create.tsx b/resources/js/pages/admin/positions/create.tsx index 8d56c51..72cb184 100644 --- a/resources/js/pages/admin/positions/create.tsx +++ b/resources/js/pages/admin/positions/create.tsx @@ -10,6 +10,7 @@ import AppLayout from '@/layouts/app-layout'; export default function Create() { const { data, setData, post, processing, errors } = useForm({ name: '', + basic_salary: '', }); const submit = (e: React.FormEvent) => { @@ -20,7 +21,7 @@ export default function Create() { return ( - +
@@ -39,18 +40,43 @@ export default function Create() {
- -
- - setData('name', e.target.value)} - /> - {errors.name &&
{errors.name}
} + +
+ {/* Nama Jabatan */} +
+ + setData('name', e.target.value)} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Gaji Pokok */} +
+ + setData('basic_salary', e.target.value)} + /> +

+ Nilai ini akan otomatis terisi saat membuat Payroll untuk karyawan dengan jabatan ini. +

+ {errors.basic_salary && ( +

{errors.basic_salary}

+ )} +
-
+
diff --git a/resources/js/pages/admin/positions/edit.tsx b/resources/js/pages/admin/positions/edit.tsx index d1f58ea..23728f2 100644 --- a/resources/js/pages/admin/positions/edit.tsx +++ b/resources/js/pages/admin/positions/edit.tsx @@ -10,6 +10,7 @@ import AppLayout from '@/layouts/app-layout'; interface Position { id: number; name: string; + basic_salary: number | null; } interface PageProps { @@ -19,6 +20,7 @@ interface PageProps { export default function Edit({ position }: PageProps) { const { data, setData, put, processing, errors } = useForm({ name: position.name, + basic_salary: position.basic_salary ?? '', }); const submit = (e: React.FormEvent) => { @@ -29,12 +31,12 @@ export default function Edit({ position }: PageProps) { return ( - +

Edit Jabatan

-

Perbarui nama jabatan.

+

Perbarui nama dan gaji pokok jabatan.

diff --git a/resources/js/pages/admin/positions/index.tsx b/resources/js/pages/admin/positions/index.tsx index 668f5ca..dc1eea5 100644 --- a/resources/js/pages/admin/positions/index.tsx +++ b/resources/js/pages/admin/positions/index.tsx @@ -33,7 +33,14 @@ export default function Index({ positions }: PageProps) {
- + + {/* Page Header */} +
+

Data Jabatan

+

Kelola level dan posisi pekerjaan.

+
+ + {/* Flash Messages */} {flash?.success && (
{flash.success} @@ -45,19 +52,12 @@ export default function Index({ positions }: PageProps) {
)} -
-
-

Data Jabatan

-

Kelola level dan posisi pekerjaan.

-
- -
- - + Daftar Jabatan + diff --git a/resources/js/pages/admin/users/create.tsx b/resources/js/pages/admin/users/create.tsx index d6b3600..7d1b819 100644 --- a/resources/js/pages/admin/users/create.tsx +++ b/resources/js/pages/admin/users/create.tsx @@ -1,158 +1,162 @@ -import { Head, Link, useForm } from '@inertiajs/react'; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import AppLayout from '@/layouts/app-layout'; -import { type BreadcrumbItem } from '@/types'; -const breadcrumbs: BreadcrumbItem[] = [ - { title: 'Manajemen Pengguna', href: '/admin/users' }, - { title: 'Tambah User', href: '/admin/users/create' }, -]; +// ─── Types ─────────────────────────────────────────────────────────────────── -export default function Create() { +interface Employee { + id: number; + name: string; + nip: string; + position: string; +} + +interface PageProps { + employees: Employee[]; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export default function Create({ employees }: PageProps) { const { data, setData, post, processing, errors } = useForm({ - name: '', + employee_id: '', email: '', - password: '', - password_confirmation: '', role: '', }); + const selectedEmployee = employees.find((e) => String(e.id) === data.employee_id) ?? null; + const submit = (e: React.FormEvent) => { e.preventDefault(); post('/admin/users'); }; return ( - - + +
-

Tambah User Baru

-

Buat akun pengguna baru beserta hak aksesnya.

+

Buat Akun User

+

+ Hubungkan akun login dengan data karyawan yang sudah terdaftar. +

- + - Form User + Form Akun Baru - + -
- {/* Nama */} -
- - setData('name', e.target.value)} - autoComplete="off" - /> - {errors.name && ( -

{errors.name}

- )} -
- - {/* Email */} -
- - setData('email', e.target.value)} - autoComplete="off" - /> - {errors.email && ( -

{errors.email}

- )} -
- - {/* Password */} -
- - setData('password', e.target.value)} - autoComplete="new-password" - /> - {errors.password && ( -

{errors.password}

- )} -
- - {/* Konfirmasi Password */} -
- - setData('password_confirmation', e.target.value)} - autoComplete="new-password" - /> - {errors.password_confirmation && ( -

{errors.password_confirmation}

- )} -
- - {/* Role */} -
- - setData('employee_id', val)}> + + - Admin - Employee + {employees.map((emp) => ( + + {emp.name} + + ({emp.nip} — {emp.position}) + + + ))} - {errors.role && ( -

{errors.role}

- )} -
+ )} + {errors.employee_id && ( +

{errors.employee_id}

+ )}
-
+ {/* Info karyawan terpilih */} + {selectedEmployee && ( +
+

Nama akun akan dibuat sebagai:

+

{selectedEmployee.name}

+

+ Password default: password +

+
+ )} + + {/* Email */} +
+ + setData('email', e.target.value)} + /> + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Role */} +
+ + + {errors.role && ( +

{errors.role}

+ )} +
+ + + +
+ -
diff --git a/resources/js/pages/admin/users/index.tsx b/resources/js/pages/admin/users/index.tsx index 11c03b1..d2f8692 100644 --- a/resources/js/pages/admin/users/index.tsx +++ b/resources/js/pages/admin/users/index.tsx @@ -47,6 +47,12 @@ export default function Index({ users }: PageProps) {
+ {/* Page Header */} +
+

Manajemen Akses User

+

Kelola akun dan hak akses pengguna sistem.

+
+ {/* Flash Messages */} {flash?.success && (
@@ -59,21 +65,13 @@ export default function Index({ users }: PageProps) {
)} - {/* Header */} -
-
-

Manajemen Akses User

-

Kelola akun dan hak akses pengguna sistem.

-
- -
- {/* Tabel */} - + Daftar Pengguna + diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index 685118f..f51d48c 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -1,120 +1,155 @@ -import { Form, Head } from '@inertiajs/react'; -import InputError from '@/components/input-error'; -import TextLink from '@/components/text-link'; +import { Head, useForm } from '@inertiajs/react'; +import React from 'react'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Spinner } from '@/components/ui/spinner'; -import AuthLayout from '@/layouts/auth-layout'; -import { register } from '@/routes'; -import { store } from '@/routes/login'; -import { request } from '@/routes/password'; + +// ─── Types ─────────────────────────────────────────────────────────────────── type Props = { status?: string; canResetPassword: boolean; - canRegister: boolean; }; -export default function Login({ - status, - canResetPassword, - canRegister, -}: Props) { - return ( - - +// ─── Component ─────────────────────────────────────────────────────────────── -
- {({ processing, errors }) => ( - <> -
-
- +export default function Login({ status, canResetPassword }: Props) { + const { data, setData, post, processing, errors } = useForm({ + email: '', + password: '', + remember: false as boolean, + }); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + post('/login'); + }; + + return ( + // Layar penuh, tengah, background abu muda +
+ + +
+ + {/* Logo / Nama Aplikasi */} +
+
+ + + + + + +
+

HRIS App

+

Masuk ke panel manajemen

+
+ + {/* Card Login */} + + + {/* Status (misal: password reset berhasil) */} + {status && ( +
+ {status} +
+ )} +
+ + + + + {/* Email */} +
+ setData('email', e.target.value)} /> - + {errors.email && ( +

{errors.email}

+ )}
-
-
+ {/* Password */} +
+
{canResetPassword && ( - - Forgot password? - + Lupa password? + )}
setData('password', e.target.value)} /> - + {errors.password && ( +

{errors.password}

+ )}
-
+ {/* Remember Me */} +
+ setData('remember', checked === true) + } /> - +
+ {/* Tombol Login */} -
- {canRegister && ( -
- Don't have an account?{' '} - - Sign up - -
- )} - - )} - + + + - {status && ( -
- {status} -
- )} - + {/* Footer */} +

+ © {new Date().getFullYear()} HRIS App. All rights reserved. +

+ +
+
); } diff --git a/routes/web.php b/routes/web.php index 9e0b12b..42576e4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,17 +2,15 @@ use App\Http\Controllers\Admin\DepartmentController; use App\Http\Controllers\Admin\EmployeeController; +use App\Http\Controllers\Admin\PayrollController; use App\Http\Controllers\Admin\PositionController; -use App\Http\Controllers\Admin\UserController; +use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\DashboardController; use Illuminate\Support\Facades\Route; -use Inertia\Inertia; -use Laravel\Fortify\Features; +// Redirect root ke halaman login Route::get('/', function () { - return Inertia::render('welcome', [ - 'canRegister' => Features::enabled(Features::registration()), - ]); + return redirect('/login'); })->name('home'); Route::get('dashboard', [DashboardController::class, 'index']) @@ -24,6 +22,7 @@ Route::resource('departments', DepartmentController::class); Route::resource('positions', PositionController::class); Route::resource('employees', EmployeeController::class); + Route::resource('payrolls', PayrollController::class)->only(['index', 'create', 'store', 'show']); Route::get('users', [UserController::class, 'index'])->name('users.index'); Route::get('users/create', [UserController::class, 'create'])->name('users.create');