Feat: Payroll feature with refactor UI Management

This commit is contained in:
IlhamIslamy 2026-04-14 23:53:38 +07:00
parent 9fb75ac5cd
commit cd50b5b10d
27 changed files with 2272 additions and 342 deletions

View File

@ -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,
],
],
]);
}
}

View File

@ -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);

View File

@ -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.");
}
}

34
app/Models/Payroll.php Normal file
View File

@ -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);
}
}

View File

@ -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()
{

View File

@ -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;

View File

@ -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');
});
}
};

View File

@ -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');
}
};

View File

@ -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');
});
}
};

View File

@ -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)
]);
}
}

View File

@ -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.');
}
}

View File

@ -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.');
}
}

View File

@ -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.');
}
}

View File

@ -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',

View File

@ -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 };

View File

@ -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">

View File

@ -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}

View File

@ -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 &quot;Tambah Komponen&quot; 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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">

View File

@ -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">
&copy; {new Date().getFullYear()} HRIS App. All rights reserved.
</p>
</div>
</div>
);
}

View File

@ -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');