Feat: Payroll feature with refactor UI Management
This commit is contained in:
parent
9fb75ac5cd
commit
cd50b5b10d
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
/**
|
||||
* Tampilkan daftar semua payroll dengan Eager Loading (hindari N+1).
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$payrolls = Payroll::with('employee')
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'period' => $p->period,
|
||||
'net_salary' => $p->net_salary,
|
||||
'status' => $p->status,
|
||||
'employee_name' => $p->employee?->name ?? '-',
|
||||
'employee_nip' => $p->employee?->nip ?? '-',
|
||||
]);
|
||||
|
||||
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,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,68 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return Inertia::render('admin/users/index', [
|
||||
'users' => 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.");
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Employee;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return Inertia::render('admin/users/index', [
|
||||
'users' => 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Payroll extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'employee_id',
|
||||
'period',
|
||||
'basic_salary',
|
||||
'details',
|
||||
'net_salary',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'details' => 'array',
|
||||
'basic_salary' => 'integer',
|
||||
'net_salary' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relasi ke Employee
|
||||
*/
|
||||
public function employee()
|
||||
{
|
||||
return $this->belongsTo(Employee::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('basic_salary')->default(0)->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->dropColumn('basic_salary');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payrolls', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Tambah kolom employee_id (nullable) pada tabel users.
|
||||
* Nullable karena akun admin tidak harus punya profil Employee.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* EmployeeSeeder
|
||||
*
|
||||
* Membuat akun User (role: employee) beserta profil Employee yang
|
||||
* terhubung ke Department dan Position yang sudah dibuat oleh MasterDataSeeder.
|
||||
*
|
||||
* Variasi kasus yang di-cover:
|
||||
* - Karyawan tetap (PKWTT) di berbagai departemen
|
||||
* - Karyawan kontrak (PKWT)
|
||||
* - Karyawan magang
|
||||
* - Berbagai jabatan dengan gaji pokok berbeda
|
||||
*/
|
||||
class EmployeeSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// ── Resolve Master Data by Name (aman terhadap perubahan ID) ──
|
||||
$depts = Department::pluck('id', 'name');
|
||||
$positions = Position::pluck('id', 'name');
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// DATA KARYAWAN
|
||||
// Format: [user_data, employee_data]
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
$employees = [
|
||||
|
||||
// ── IT DEPARTMENT ────────────────────────────────────────
|
||||
|
||||
[
|
||||
'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' => '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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* PayrollSeeder
|
||||
*
|
||||
* Membuat data payroll dummy yang realistis dan terhubung ke employee.
|
||||
* Seeder ini HARUS dijalankan setelah EmployeeSeeder.
|
||||
*
|
||||
* Variasi kasus yang di-cover:
|
||||
* - Status 'paid' (bulan lalu) dan 'pending' (bulan ini)
|
||||
* - Karyawan dengan bonus lembur, tunjangan jabatan, tunjangan transport
|
||||
* - Karyawan dengan potongan (BPJS, absen, pinjaman, keterlambatan)
|
||||
* - Karyawan magang (gaji pokok rendah, komponen minimal)
|
||||
* - Karyawan senior dengan tunjangan lengkap
|
||||
*/
|
||||
class PayrollSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Helper: hitung net_salary otomatis (sama dengan logika Controller)
|
||||
$net = function (int $basic, array $details): int {
|
||||
$total = $basic;
|
||||
foreach ($details as $item) {
|
||||
$total += $item['type'] === 'bonus' ? $item['amount'] : -$item['amount'];
|
||||
}
|
||||
return max(0, $total);
|
||||
};
|
||||
|
||||
// Resolve semua employee sekaligus (1 query, bukan N query)
|
||||
$emp = Employee::pluck('id', 'name');
|
||||
|
||||
// Periode referensi
|
||||
$bulanLalu = now()->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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
|
@ -39,7 +39,14 @@ export default function Index({ departments }: PageProps) {
|
|||
<Head title="Manajemen Departemen" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Departemen</h2>
|
||||
<p className="text-muted-foreground">Kelola struktur organisasi dan unit kerja.</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}
|
||||
|
|
@ -51,19 +58,12 @@ export default function Index({ departments }: PageProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Departemen</h2>
|
||||
<p className="text-muted-foreground">Kelola struktur organisasi dan unit kerja.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/departments/create">+ Tambah Departemen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Departemen</CardTitle>
|
||||
<Button asChild>
|
||||
<Link href="/admin/departments/create">+ Tambah Departemen</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-0">
|
||||
|
|
|
|||
|
|
@ -77,7 +77,15 @@ export default function Index({ employees, departments, filters }: PageProps) {
|
|||
return (
|
||||
<AppLayout>
|
||||
<Head title="Manajemen Karyawan" />
|
||||
<div className="p-4 md:p-8 space-y-4">
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Karyawan</h2>
|
||||
<p className="text-muted-foreground">Kelola data seluruh karyawan perusahaan.</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}
|
||||
|
|
|
|||
|
|
@ -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<Employee | null>(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 (
|
||||
<AppLayout>
|
||||
<Head title="Generate Payroll" />
|
||||
|
||||
<div className="mx-auto max-w-4xl p-4 md:p-8">
|
||||
{/* Back button */}
|
||||
<Button variant="outline" asChild className="mb-6">
|
||||
<Link href="/admin/payrolls">← Kembali</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form Generate Payroll</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pilih karyawan dan periode, lalu tambahkan komponen bonus/potongan jika diperlukan.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-8">
|
||||
|
||||
{/* ── SEKSI 1: Informasi Utama ── */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||||
1
|
||||
</span>
|
||||
Informasi Utama
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Pilih Karyawan */}
|
||||
<div className="space-y-2">
|
||||
<Label>Karyawan</Label>
|
||||
<Select onValueChange={handleEmployeeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih Karyawan..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={String(emp.id)}>
|
||||
{emp.name}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
({emp.nip})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.employee_id && (
|
||||
<p className="text-xs text-red-500">{errors.employee_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Periode */}
|
||||
<div className="space-y-2">
|
||||
<Label>Periode Gaji</Label>
|
||||
<Input
|
||||
type="month"
|
||||
value={data.period}
|
||||
onChange={(e) => setData('period', e.target.value)}
|
||||
/>
|
||||
{errors.period && (
|
||||
<p className="text-xs text-red-500">{errors.period}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Karyawan Terpilih */}
|
||||
{selectedEmployee && (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Departemen:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{selectedEmployee.department.name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Jabatan:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{selectedEmployee.position.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── SEKSI 2: Gaji Pokok (Auto-fill) ── */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||||
2
|
||||
</span>
|
||||
Gaji Pokok
|
||||
</h3>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label>
|
||||
Gaji Pokok{' '}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(otomatis dari jabatan)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.basic_salary}
|
||||
onChange={(e) =>
|
||||
setData('basic_salary', Number(e.target.value))
|
||||
}
|
||||
className="font-medium"
|
||||
/>
|
||||
{errors.basic_salary && (
|
||||
<p className="text-xs text-red-500">{errors.basic_salary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── SEKSI 3: Komponen Bonus / Potongan ── */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||||
3
|
||||
</span>
|
||||
Komponen Gaji
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addDetail}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Tambah Komponen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.details.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Belum ada komponen tambahan. Klik "Tambah Komponen" untuk menambahkan bonus atau potongan.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Header kolom */}
|
||||
<div className="grid grid-cols-[1fr_140px_160px_40px] gap-3 px-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Nama Komponen</span>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tipe</span>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Nominal (Rp)</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{data.details.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[1fr_140px_160px_40px] items-center gap-3"
|
||||
>
|
||||
{/* Nama Komponen */}
|
||||
<Input
|
||||
placeholder="cth: Bonus Kinerja"
|
||||
value={item.name}
|
||||
onChange={(e) =>
|
||||
updateDetail(index, 'name', e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tipe */}
|
||||
<Select
|
||||
value={item.type}
|
||||
onValueChange={(val) =>
|
||||
updateDetail(index, 'type', val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bonus">Bonus</SelectItem>
|
||||
<SelectItem value="deduction">Potongan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Nominal */}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
value={item.amount || ''}
|
||||
onChange={(e) =>
|
||||
updateDetail(index, 'amount', e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Hapus */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeDetail(index)}
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── RINGKASAN GAJI BERSIH ── */}
|
||||
<div className="rounded-lg border bg-primary/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Gaji Bersih (Estimasi)</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Gaji Pokok + Bonus − Potongan
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-primary">
|
||||
{formatRupiah(Math.max(0, netSalary))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TOMBOL AKSI ── */}
|
||||
<div className="flex justify-end gap-4 pt-2">
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href="/admin/payrolls">Batal</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || !data.employee_id || !data.period}
|
||||
className="px-8"
|
||||
>
|
||||
{processing ? 'Menyimpan...' : 'Generate Payroll'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<AppLayout>
|
||||
<Head title="Daftar Payroll" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Payroll</h2>
|
||||
<p className="text-muted-foreground">Daftar seluruh payroll yang telah di-generate.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Slip Gaji</CardTitle>
|
||||
<Button asChild>
|
||||
<Link href="/admin/payrolls/create" className="gap-2">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Generate Payroll
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{payrolls.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<FileText className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Belum ada data payroll.
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link href="/admin/payrolls/create">Generate Pertama</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Karyawan</TableHead>
|
||||
<TableHead>Periode</TableHead>
|
||||
<TableHead className="text-right">Gaji Bersih</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-right">Aksi</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payrolls.map((payroll, index) => (
|
||||
<TableRow key={payroll.id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{payroll.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
NIP: {payroll.employee_nip}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatPeriod(payroll.period)}</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatRupiah(payroll.net_salary)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusBadge status={payroll.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/admin/payrolls/${payroll.id}`}>
|
||||
Lihat Slip
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-component: Status Badge ─────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
|
||||
if (status === 'paid') {
|
||||
return (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
|
||||
Paid
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100">
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className={`text-sm ${valueClass}`}>
|
||||
{prefix}{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
|
||||
return status === 'paid' ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 border-emerald-200">
|
||||
Lunas
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200">
|
||||
Belum Dibayar
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<AppLayout>
|
||||
<Head title={`Slip Gaji — ${payroll.employee.name}`} />
|
||||
|
||||
<div className="mx-auto max-w-2xl p-4 md:p-8 print:p-0 print:max-w-none">
|
||||
|
||||
{/* Toolbar — tersembunyi saat print */}
|
||||
<div className="mb-5 flex items-center justify-between print:hidden">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/payrolls">← Kembali</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Cetak
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Slip Card ── */}
|
||||
<Card className="overflow-hidden border shadow-sm print:shadow-none print:border-none print:rounded-none">
|
||||
|
||||
{/* ── HEADER ── */}
|
||||
<div className="border-b bg-zinc-50 px-6 py-5 print:break-inside-avoid">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Nama Perusahaan */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold leading-none text-foreground">
|
||||
PT. HRIS Nusantara
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Slip Gaji Karyawan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Meta slip */}
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<Hash className="h-3 w-3" />
|
||||
<span className="font-mono font-medium">
|
||||
{String(payroll.id).padStart(5, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
<span>{payroll.created_at}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<StatusBadge status={payroll.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
|
||||
{/* ── INFO KARYAWAN ── */}
|
||||
<div className="border-b px-6 py-5 print:break-inside-avoid">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Informasi Karyawan
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
||||
<InfoCell label="Nama" value={payroll.employee.name} />
|
||||
<InfoCell label="NIP" value={payroll.employee.nip} />
|
||||
<InfoCell label="Jabatan" value={payroll.employee.position ?? '-'} />
|
||||
<InfoCell label="Departemen" value={payroll.employee.department ?? '-'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── PERIODE ── */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-6 py-3 print:break-inside-avoid">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Periode Penggajian
|
||||
</p>
|
||||
<p className="text-sm font-bold text-foreground">
|
||||
{formatPeriod(payroll.period)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── RINCIAN GAJI ── */}
|
||||
<div className="px-6 py-5 space-y-1 print:break-inside-avoid">
|
||||
|
||||
{/* Gaji Pokok */}
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Komponen Gaji
|
||||
</p>
|
||||
<LineItem
|
||||
label="Gaji Pokok"
|
||||
value={formatRupiah(payroll.basic_salary)}
|
||||
/>
|
||||
|
||||
{/* Bonus */}
|
||||
{bonuses.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<p className="pb-0.5 text-[11px] font-semibold uppercase tracking-widest text-emerald-600">
|
||||
Tambahan
|
||||
</p>
|
||||
{bonuses.map((item, i) => (
|
||||
<LineItem
|
||||
key={i}
|
||||
label={item.name}
|
||||
value={formatRupiah(item.amount)}
|
||||
type="bonus"
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-between pt-1 text-xs">
|
||||
<span className="text-muted-foreground">Subtotal tambahan</span>
|
||||
<span className="font-semibold text-emerald-600">
|
||||
+ {formatRupiah(totalBonus)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Potongan */}
|
||||
{deductions.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<p className="pb-0.5 text-[11px] font-semibold uppercase tracking-widest text-red-500">
|
||||
Potongan
|
||||
</p>
|
||||
{deductions.map((item, i) => (
|
||||
<LineItem
|
||||
key={i}
|
||||
label={item.name}
|
||||
value={formatRupiah(item.amount)}
|
||||
type="deduction"
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-between pt-1 text-xs">
|
||||
<span className="text-muted-foreground">Subtotal potongan</span>
|
||||
<span className="font-semibold text-red-500">
|
||||
− {formatRupiah(totalDeduction)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── TOTAL GAJI BERSIH ── */}
|
||||
<div className="border-t bg-zinc-50 px-6 py-5 print:break-inside-avoid">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Total Gaji Bersih
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gaji Pokok
|
||||
{bonuses.length > 0 && ' + Tambahan'}
|
||||
{deductions.length > 0 && ' − Potongan'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{formatRupiah(payroll.net_salary)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── FOOTER ── */}
|
||||
<div className="border-t px-6 py-3">
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Dokumen ini diterbitkan secara otomatis oleh sistem HRIS dan sah tanpa tanda tangan basah.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<AppLayout>
|
||||
<Head title="Tambah Jabatan" />
|
||||
|
||||
|
||||
<div className="p-4 md:p-8 w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
|
|
@ -39,18 +40,43 @@ export default function Create() {
|
|||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
|
||||
<div className="max-w-xl space-y-2">
|
||||
<Label>Nama Jabatan <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="Contoh: Senior Backend Developer"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
|
||||
|
||||
<div className="max-w-xl space-y-4">
|
||||
{/* Nama Jabatan */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Nama Jabatan <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Contoh: Senior Backend Developer"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-xs">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gaji Pokok */}
|
||||
<div className="space-y-2">
|
||||
<Label>Gaji Pokok (Rp)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Contoh: 5000000"
|
||||
value={data.basic_salary}
|
||||
onChange={e => setData('basic_salary', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nilai ini akan otomatis terisi saat membuat Payroll untuk karyawan dengan jabatan ini.
|
||||
</p>
|
||||
{errors.basic_salary && (
|
||||
<p className="text-red-500 text-xs">{errors.basic_salary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start gap-4 pt-4">
|
||||
<div className="flex justify-start gap-4 pt-2">
|
||||
<Button type="submit" disabled={processing}>
|
||||
Simpan Jabatan
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AppLayout>
|
||||
<Head title="Edit Jabatan" />
|
||||
|
||||
|
||||
<div className="p-4 md:p-8 w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Jabatan</h2>
|
||||
<p className="text-muted-foreground">Perbarui nama jabatan.</p>
|
||||
<p className="text-muted-foreground">Perbarui nama dan gaji pokok jabatan.</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/positions">Kembali</Link>
|
||||
|
|
@ -48,17 +50,40 @@ export default function Edit({ position }: PageProps) {
|
|||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
|
||||
<div className="max-w-xl space-y-2">
|
||||
<Label>Nama Jabatan</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
|
||||
|
||||
<div className="max-w-xl space-y-4">
|
||||
{/* Nama Jabatan */}
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Jabatan</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-xs">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gaji Pokok */}
|
||||
<div className="space-y-2">
|
||||
<Label>Gaji Pokok (Rp)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Contoh: 5000000"
|
||||
value={data.basic_salary}
|
||||
onChange={e => setData('basic_salary', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Perubahan ini TIDAK mempengaruhi data payroll yang sudah ada.
|
||||
</p>
|
||||
{errors.basic_salary && (
|
||||
<p className="text-red-500 text-xs">{errors.basic_salary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start gap-4 pt-4">
|
||||
<div className="flex justify-start gap-4 pt-2">
|
||||
<Button type="submit" disabled={processing}>
|
||||
Simpan Perubahan
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,14 @@ export default function Index({ positions }: PageProps) {
|
|||
<Head title="Manajemen Jabatan" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Jabatan</h2>
|
||||
<p className="text-muted-foreground">Kelola level dan posisi pekerjaan.</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}
|
||||
|
|
@ -45,19 +52,12 @@ export default function Index({ positions }: PageProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Jabatan</h2>
|
||||
<p className="text-muted-foreground">Kelola level dan posisi pekerjaan.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/positions/create">+ Tambah Jabatan</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Jabatan</CardTitle>
|
||||
<Button asChild>
|
||||
<Link href="/admin/positions/create">+ Tambah Jabatan</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-0">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Tambah User Baru" />
|
||||
<AppLayout>
|
||||
<Head title="Buat Akun User" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Tambah User Baru</h2>
|
||||
<p className="text-muted-foreground">Buat akun pengguna baru beserta hak aksesnya.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Buat Akun User</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Hubungkan akun login dengan data karyawan yang sudah terdaftar.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/users">Kembali</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Form User</CardTitle>
|
||||
<CardTitle>Form Akun Baru</CardTitle>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Nama */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Lengkap <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Contoh: Budi Santoso"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-xs">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Contoh: budi@hris.com"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-xs">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Minimal 8 karakter"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-xs">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Konfirmasi Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Konfirmasi Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
placeholder="Ulangi password di atas"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.password_confirmation && (
|
||||
<p className="text-red-500 text-xs">{errors.password_confirmation}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">
|
||||
Role / Hak Akses <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={data.role}
|
||||
onValueChange={(val) => setData('role', val)}
|
||||
>
|
||||
<SelectTrigger id="role" className="w-full">
|
||||
<SelectValue placeholder="Pilih role..." />
|
||||
{/* Pilih Karyawan */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Karyawan <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{employees.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
Semua karyawan sudah memiliki akun.
|
||||
</div>
|
||||
) : (
|
||||
<Select onValueChange={(val) => setData('employee_id', val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih karyawan..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={String(emp.id)}>
|
||||
<span className="font-medium">{emp.name}</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
({emp.nip} — {emp.position})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.role && (
|
||||
<p className="text-red-500 text-xs">{errors.role}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{errors.employee_id && (
|
||||
<p className="text-xs text-red-500">{errors.employee_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
{/* Info karyawan terpilih */}
|
||||
{selectedEmployee && (
|
||||
<div className="rounded-md bg-muted/40 border px-4 py-3 text-sm">
|
||||
<p className="text-muted-foreground text-xs mb-1">Nama akun akan dibuat sebagai:</p>
|
||||
<p className="font-semibold">{selectedEmployee.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Password default: <span className="font-mono font-medium">password</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="nama@perusahaan.com"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Role <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select onValueChange={(val) => setData('role', val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih role..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.role && (
|
||||
<p className="text-xs text-red-500">{errors.role}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-start gap-3 pt-1">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || !data.employee_id}
|
||||
>
|
||||
{processing ? 'Menyimpan...' : 'Buat Akun'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href="/admin/users">Batal</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan User'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ export default function Index({ users }: PageProps) {
|
|||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Akses User</h2>
|
||||
<p className="text-muted-foreground">Kelola akun dan hak akses pengguna sistem.</p>
|
||||
</div>
|
||||
|
||||
{/* Flash Messages */}
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
|
|
@ -59,21 +65,13 @@ export default function Index({ users }: PageProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Akses User</h2>
|
||||
<p className="text-muted-foreground">Kelola akun dan hak akses pengguna sistem.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/users/create">+ Tambah User Baru</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Pengguna</CardTitle>
|
||||
<Button asChild>
|
||||
<Link href="/admin/users/create">+ Tambah User Baru</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-0">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AuthLayout
|
||||
title="Log in to your account"
|
||||
description="Enter your email and password below to log in"
|
||||
>
|
||||
<Head title="Log in" />
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password']}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
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
|
||||
<div className="min-h-screen bg-zinc-50 flex items-center justify-center px-4">
|
||||
<Head title="Login — HRIS" />
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
{/* Logo / Nama Aplikasi */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-6 w-6 text-primary-foreground"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">HRIS App</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Masuk ke panel manajemen</p>
|
||||
</div>
|
||||
|
||||
{/* Card Login */}
|
||||
<Card className="border shadow-sm">
|
||||
<CardHeader className="pb-0 pt-6 px-6">
|
||||
{/* Status (misal: password reset berhasil) */}
|
||||
{status && (
|
||||
<div className="mb-2 rounded-md bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-6 pb-6 pt-4">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="nama@perusahaan.com"
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
placeholder="email@example.com"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink
|
||||
href={request()}
|
||||
className="ml-auto text-sm"
|
||||
tabIndex={5}
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</TextLink>
|
||||
Lupa password?
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
{errors.password && (
|
||||
<p className="text-xs text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Remember Me */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
tabIndex={3}
|
||||
checked={data.remember}
|
||||
onCheckedChange={(checked) =>
|
||||
setData('remember', checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
<Label
|
||||
htmlFor="remember"
|
||||
className="text-sm font-normal text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Ingat saya
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Tombol Login */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
tabIndex={4}
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
data-test="login-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Log in
|
||||
{processing ? 'Memproses...' : 'Masuk'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canRegister && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
Sign up
|
||||
</TextLink>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} HRIS App. All rights reserved.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue