Compare commits

...

10 Commits

Author SHA1 Message Date
IlhamIslamy c2cb69924c feat: implement user profile settings page with layout and custom app base view 2026-05-30 20:08:07 +07:00
IlhamIslamy c9eacaee46 feat: configure middleware to trust all proxies in application bootstrap 2026-05-29 16:39:31 +07:00
IlhamIslamy 5a7e027ad4 chore: update PHP version requirement to 8.3 and initialize railpack configuration 2026-05-29 15:58:25 +07:00
IlhamIslamy df88547cc3 - Mengubah `user_id` menjadi nullable dan menambah kolom `email` pada tabel employees
- Memperbarui EmployeeController agar pembuatan data karyawan tidak otomatis membuat akun user
- Menambahkan sinkronisasi update data karyawan ke akun user (jika sudah tertaut)
- Menyesuaikan UI Frontend (Create, Edit, Index) agar membaca data langsung dari objek employee
- Menambahkan icon Settings dan tombol Logout pada AdminLayout
- Menyesuaikan host localhost pada konfigurasi Vite
2026-05-21 00:56:00 +07:00
IlhamIslamy 285db59bde feat: implement core HRIS features including dashboard, employee management, departments, positions, payroll, and attendance modules 2026-05-08 02:02:40 +07:00
IlhamIslamy 7d4e9fe307 Merge branch 'main' of https://github.com/IlhamIslamy/HR_Management__App 2026-04-27 22:48:08 +07:00
IlhamIslamy a3ad24e029 feat: implement attendance tracking system with admin and employee dashboards 2026-04-27 22:45:22 +07:00
IlhamIslamy b9ace087ee feat absensi 2026-04-23 10:25:39 +07:00
IlhamIslamy 859dbe13a1 refactor UI 2026-04-15 00:10:22 +07:00
IlhamIslamy cd50b5b10d Feat: Payroll feature with refactor UI Management 2026-04-14 23:53:38 +07:00
79 changed files with 5376 additions and 713 deletions

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Carbon\Carbon;
class AttendanceController extends Controller
{
public function index(Request $request)
{
$query = Attendance::with('employee')->orderBy('date', 'desc');
if ($request->search) {
$query->whereHas('employee', function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%')
->orWhere('nip', 'like', '%' . $request->search . '%');
});
}
if ($request->date) {
$query->where('date', $request->date);
}
if ($request->status) {
$query->where('status', $request->status);
}
$attendances = $query->get()->map(function ($attendance) {
$totalHours = null;
if ($attendance->check_in && $attendance->check_out) {
$checkIn = Carbon::parse($attendance->check_in);
$checkOut = Carbon::parse($attendance->check_out);
$diff = $checkIn->diff($checkOut);
$totalHours = sprintf('%02d:%02d', $diff->h, $diff->i);
}
return [
'id' => $attendance->id,
'employee_name' => $attendance->employee->name ?? '-',
'employee_nik' => $attendance->employee->nip ?? '-',
'date' => $attendance->date,
'check_in' => $attendance->check_in,
'check_out' => $attendance->check_out,
'status' => $attendance->status,
'notes' => $attendance->notes,
'total_hours' => $totalHours,
'latitude_in' => $attendance->latitude_in,
'longitude_in' => $attendance->longitude_in,
'latitude_out' => $attendance->latitude_out,
'longitude_out'=> $attendance->longitude_out,
];
});
return Inertia::render('admin/attendances/index', [
'attendances' => $attendances,
'filters' => $request->only(['search', 'date', 'status']),
]);
}
}

View File

@ -9,10 +9,15 @@
class DepartmentController extends Controller
{
public function index()
public function index(Request $request)
{
$query = Department::withCount('employees');
if ($request->search) {
$query->where('name', 'like', '%' . $request->search . '%');
}
return Inertia::render('admin/departments/index', [
'departments' => Department::withCount('employees')->latest()->get(),
'departments' => $query->latest()->get(),
'filters' => $request->only(['search']),
]);
}
@ -26,6 +31,10 @@ public function store(Request $request)
$validated = $request->validate([
'name' => 'required|string|max:255|unique:departments,name',
'description' => 'nullable|string',
], [
'name.required' => 'Nama departemen wajib diisi.',
'name.max' => 'Nama departemen maksimal 255 karakter.',
'name.unique' => 'Nama departemen sudah terdaftar.',
]);
Department::create($validated);
@ -46,6 +55,10 @@ public function update(Request $request, Department $department)
$validated = $request->validate([
'name' => 'required|string|max:255|unique:departments,name,' . $department->id,
'description' => 'nullable|string',
], [
'name.required' => 'Nama departemen wajib diisi.',
'name.max' => 'Nama departemen maksimal 255 karakter.',
'name.unique' => 'Nama departemen sudah terdaftar.',
]);
$department->update($validated);
@ -56,7 +69,7 @@ public function update(Request $request, Department $department)
public function destroy(Department $department)
{
// Cek jika departemen masih punya karyawan
// Cek sisa karyawan
if ($department->employees()->count() > 0) {
return back()->with('error', 'Gagal hapus! Departemen ini masih memiliki karyawan.');
}

View File

@ -3,13 +3,12 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmployeeRequest;
use App\Http\Requests\UpdateEmployeeRequest;
use App\Models\Department;
use App\Models\Employee;
use App\Models\Position;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class EmployeeController extends Controller
@ -20,9 +19,7 @@ public function index(Request $request)
if ($request->search) {
$query->where('nip', 'like', '%' . $request->search . '%')
->orWhereHas('user', function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%');
});
->orWhere('name', 'like', '%' . $request->search . '%');
}
if ($request->department_id) {
@ -30,9 +27,9 @@ public function index(Request $request)
}
return Inertia::render('admin/employees/index', [
'employees' => $query->latest()->paginate(10)->withQueryString(),
'employees' => $query->latest()->get(),
'departments' => Department::all(),
'filters' => $request->only(['search', 'department_id']),
'filters' => $request->only(['search', 'department_id']),
]);
}
@ -40,46 +37,30 @@ public function create()
{
return Inertia::render('admin/employees/create', [
'departments' => Department::all(),
'positions' => Position::all(),
'positions' => Position::all(),
]);
}
public function store(Request $request)
public function store(StoreEmployeeRequest $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'nip' => 'required|string|unique:employees,nip',
'gender' => 'required|in:L,P',
'place_of_birth' => 'nullable|string|max:255',
'birth_date' => 'required|date',
'address' => 'nullable|string',
'phone_number' => 'nullable|string',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make('password123'),
]);
$validated = $request->validated();
// Simpan hanya data karyawan ke tabel employees.
// Pembuatan akun User dilakukan secara terpisah oleh admin
// melalui menu Manajemen Pengguna.
Employee::create([
'user_id' => $user->id,
'nip' => $validated['nip'],
'name' => $validated['name'],
'gender' => $validated['gender'],
'place_of_birth' => $validated['place_of_birth'],
'birth_date' => $validated['birth_date'],
'address' => $validated['address'],
'phone_number' => $validated['phone_number'],
'department_id' => $validated['department_id'],
'position_id' => $validated['position_id'],
'status' => $validated['status'],
'join_date' => $validated['join_date'],
'nip' => $validated['nip'],
'name' => $validated['name'],
'email' => $validated['email'] ?? null,
'gender' => $validated['gender'],
'place_of_birth' => $validated['place_of_birth'] ?? null,
'birth_date' => $validated['birth_date'],
'address' => $validated['address'] ?? null,
'phone_number' => $validated['phone_number'] ?? null,
'department_id' => $validated['department_id'],
'position_id' => $validated['position_id'],
'status' => $validated['status'],
'join_date' => $validated['join_date'],
]);
return redirect()->route('admin.employees.index')
@ -91,43 +72,47 @@ public function edit(Employee $employee)
$employee->load(['user', 'department', 'position']);
return Inertia::render('admin/employees/edit', [
'employee' => $employee,
'employee' => $employee,
'departments' => Department::all(),
'positions' => Position::all(),
'positions' => Position::all(),
]);
}
public function update(Request $request, Employee $employee)
public function update(UpdateEmployeeRequest $request, Employee $employee)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => ['required', 'email', Rule::unique('users')->ignore($employee->user_id)],
'nip' => ['required', 'string', Rule::unique('employees')->ignore($employee->id)],
'gender' => 'required|in:L,P',
'place_of_birth' => 'nullable|string|max:255',
'birth_date' => 'required|date',
'address' => 'nullable|string',
'phone_number' => 'nullable|string',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
]);
$validated = $request->validated();
$employee->user->update([
'name' => $validated['name'],
'email' => $validated['email'],
]);
// Sinkronisasi ke akun User jika karyawan sudah memiliki akun
if ($employee->user) {
$employee->user->update([
'name' => $validated['name'],
'email' => $validated['email'] ?? $employee->user->email,
]);
}
$employee->update($validated);
// Update data karyawan di tabel employees
$employee->update([
'name' => $validated['name'],
'email' => $validated['email'] ?? null,
'nip' => $validated['nip'],
'gender' => $validated['gender'],
'place_of_birth' => $validated['place_of_birth'] ?? null,
'birth_date' => $validated['birth_date'],
'address' => $validated['address'] ?? null,
'phone_number' => $validated['phone_number'] ?? null,
'department_id' => $validated['department_id'],
'position_id' => $validated['position_id'],
'status' => $validated['status'],
'join_date' => $validated['join_date'],
]);
return redirect()->route('admin.employees.index')
->with('success', 'Data karyawan diperbarui.');
->with('success', 'Data karyawan berhasil diperbarui.');
}
public function destroy(Employee $employee)
{
$employee->user->delete();
return back()->with('success', 'Karyawan dihapus.');
$employee->user?->delete();
return back()->with('success', 'Karyawan berhasil dihapus.');
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Employee;
use App\Models\Payroll;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class PayrollController extends Controller
{
public function index(Request $request)
{
$query = Payroll::with('employee');
if ($request->search) {
$query->whereHas('employee', function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%')
->orWhere('nip', 'like', '%' . $request->search . '%');
});
}
if ($request->period) {
$query->where('period', $request->period);
}
if ($request->status) {
$query->where('status', $request->status);
}
$payrolls = $query->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,
'filters' => $request->only(['search', 'period', 'status']),
]);
}
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,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'employee_id' => 'required|exists:employees,id',
'period' => [
'required',
'date_format:Y-m',
Rule::unique('payrolls')->where(fn ($query) => $query->where('employee_id', $request->employee_id)),
],
'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',
], [
'employee_id.required' => 'Karyawan wajib dipilih.',
'employee_id.exists' => 'Karyawan tidak ditemukan.',
'period.required' => 'Periode gaji wajib diisi.',
'period.date_format' => 'Format periode harus YYYY-MM.',
'period.unique' => 'Payroll karyawan untuk periode ini sudah ada.',
'basic_salary.required' => 'Gaji pokok wajib diisi.',
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
'details.array' => 'Detail komponen harus berupa array.',
'details.*.name.required' => 'Nama komponen wajib diisi.',
'details.*.name.max' => 'Nama komponen maksimal 100 karakter.',
'details.*.type.required' => 'Tipe komponen wajib dipilih.',
'details.*.type.in' => 'Tipe komponen hanya boleh bonus atau potongan.',
'details.*.amount.required' => 'Nominal komponen wajib diisi.',
'details.*.amount.integer' => 'Nominal komponen harus berupa angka bulat.',
'details.*.amount.min' => 'Nominal komponen tidak boleh negatif.',
]);
$net = $validated['basic_salary'];
foreach ($validated['details'] ?? [] as $item) {
$net += $item['type'] === 'bonus'
? $item['amount']
: -$item['amount'];
}
Payroll::create([
'employee_id' => $validated['employee_id'],
'period' => $validated['period'],
'basic_salary' => $validated['basic_salary'],
'details' => $validated['details'] ?? [],
'net_salary' => max(0, $net),
'status' => 'pending',
]);
return redirect('/admin/payrolls')
->with('success', 'Payroll berhasil di-generate.');
}
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,
],
],
]);
}
public function edit(Payroll $payroll)
{
$payroll->load('employee.position', 'employee.department');
return Inertia::render('admin/payrolls/edit', [
'payroll' => [
'id' => $payroll->id,
'period' => $payroll->period,
'basic_salary' => $payroll->basic_salary,
'details' => $payroll->details ?? [],
'net_salary' => $payroll->net_salary,
'status' => $payroll->status,
'employee' => [
'id' => $payroll->employee?->id,
'name' => $payroll->employee?->name,
'nip' => $payroll->employee?->nip,
'position' => $payroll->employee?->position?->name,
'department' => $payroll->employee?->department?->name,
],
],
]);
}
public function update(Request $request, Payroll $payroll)
{
$validated = $request->validate([
'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',
], [
'basic_salary.required' => 'Gaji pokok wajib diisi.',
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
'details.*.name.required' => 'Nama komponen wajib diisi.',
'details.*.type.required' => 'Tipe komponen wajib dipilih.',
'details.*.type.in' => 'Tipe hanya boleh bonus atau potongan.',
'details.*.amount.required' => 'Nominal wajib diisi.',
'details.*.amount.min' => 'Nominal tidak boleh negatif.',
]);
$net = $validated['basic_salary'];
foreach ($validated['details'] ?? [] as $item) {
$net += $item['type'] === 'bonus'
? $item['amount']
: -$item['amount'];
}
$payroll->update([
'basic_salary' => $validated['basic_salary'],
'details' => $validated['details'] ?? [],
'net_salary' => max(0, $net),
]);
return redirect()->route('admin.payrolls.show', $payroll)
->with('success', 'Data payroll berhasil diperbarui.');
}
/**
* Update hanya field status payroll (menunggu / telah_dikirim).
*/
public function updateStatus(Request $request, Payroll $payroll)
{
$validated = $request->validate([
'status' => ['required', Rule::in(['pending', 'paid'])],
], [
'status.required' => 'Status wajib dipilih.',
'status.in' => 'Status hanya boleh pending atau paid.',
]);
$payroll->update(['status' => $validated['status']]);
$label = $validated['status'] === 'paid' ? 'Telah Dikirim' : 'Menunggu';
return back()->with('success', "Status payroll diubah menjadi '{$label}'.");
}
}

View File

@ -9,10 +9,15 @@
class PositionController extends Controller
{
public function index()
public function index(Request $request)
{
$query = Position::withCount('employees');
if ($request->search) {
$query->where('name', 'like', '%' . $request->search . '%');
}
return Inertia::render('admin/positions/index', [
'positions' => Position::withCount('employees')->latest()->get(),
'positions' => $query->latest()->get(),
'filters' => $request->only(['search']),
]);
}
@ -24,7 +29,14 @@ 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',
], [
'name.required' => 'Nama jabatan wajib diisi.',
'name.max' => 'Nama jabatan maksimal 255 karakter.',
'name.unique' => 'Nama jabatan sudah terdaftar.',
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
]);
Position::create($validated);
@ -43,7 +55,14 @@ 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',
], [
'name.required' => 'Nama jabatan wajib diisi.',
'name.max' => 'Nama jabatan maksimal 255 karakter.',
'name.unique' => 'Nama jabatan sudah terdaftar.',
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
]);
$position->update($validated);

View File

@ -1,68 +1,101 @@
<?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(Request $request)
{
$query = User::select('id', 'name', 'email', 'role', 'created_at');
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%')
->orWhere('email', 'like', '%' . $request->search . '%');
});
}
if ($request->role) {
$query->where('role', $request->role);
}
return Inertia::render('admin/users/index', [
'users' => $query->latest()->get(),
'filters' => $request->only(['search', 'role']),
]);
}
public function create()
{
// Filter employee tanpa akun
$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,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'employee_id' => [
'required',
'exists:employees,id',
'unique:users,employee_id',
],
'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.',
]);
$employee = Employee::findOrFail($validated['employee_id']);
$user = User::create([
'name' => $employee->name,
'email' => $validated['email'],
'password' => Hash::make('password'),
'role' => $validated['role'],
'employee_id' => $employee->id,
]);
$employee->update(['user_id' => $user->id]);
return redirect()->route('admin.users.index')
->with('success', "Akun untuk {$employee->name} berhasil dibuat.");
}
public function update(Request $request, User $user)
{
$validated = $request->validate([
'role' => ['required', Rule::in(['admin', 'employee'])],
], [
'role.required' => 'Role wajib dipilih.',
'role.in' => 'Role hanya boleh admin atau employee.',
]);
$user->update($validated);
return back()->with('success', "Role {$user->name} berhasil diubah.");
}
}

View File

@ -4,5 +4,4 @@
abstract class Controller
{
//
}

View File

@ -4,8 +4,10 @@
use App\Models\Department;
use App\Models\Employee;
use App\Models\Payroll;
use App\Models\Position;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Inertia\Inertia;
class DashboardController extends Controller
@ -16,26 +18,31 @@ public function index()
return redirect()->route('employee.index');
}
$currentPeriod = Carbon::now()->format('Y-m');
$stats = [
'total_employees' => Employee::count(),
'total_employees' => Employee::count(),
'total_departments' => Department::count(),
'total_positions' => Position::count(),
'total_positions' => Position::count(),
'payroll_realisasi' => Payroll::where('period', $currentPeriod)
->where('status', 'paid')
->sum('net_salary'),
];
$genderData = Employee::selectRaw('gender, count(*) as total')
->groupBy('gender')
->get()
->map(fn($item) => [
'name' => $item->gender == 'L' ? 'Laki-laki' : 'Perempuan',
'name' => $item->gender == 'L' ? 'Laki-laki' : 'Perempuan',
'value' => $item->total,
'fill' => $item->gender == 'L' ? '#3b82f6' : '#ec4899',
'fill' => $item->gender == 'L' ? '#3b82f6' : '#ec4899',
]);
$deptData = Department::withCount('employees')
->having('employees_count', '>', 0)
->get()
->map(fn($item) => [
'name' => $item->name,
'name' => $item->name,
'employees' => $item->employees_count,
]);
@ -45,10 +52,11 @@ public function index()
->get();
return Inertia::render('dashboard', [
'stats' => $stats,
'genderData' => $genderData,
'deptData' => $deptData,
'stats' => $stats,
'genderData' => $genderData,
'deptData' => $deptData,
'latestEmployees' => $latestEmployees,
'currentPeriod' => $currentPeriod,
]);
}
}

View File

@ -0,0 +1,177 @@
<?php
namespace App\Http\Controllers\Employee;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use Illuminate\Http\Request;
use Carbon\Carbon;
use Inertia\Inertia;
class EmployeeAttendanceController extends Controller
{
private function checkEmployee(Request $request)
{
return $request->user()->employee;
}
public function index(Request $request)
{
$employee = $this->checkEmployee($request);
if (!$employee) {
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
}
$employeeId = $employee->id;
$attendances = Attendance::where('employee_id', $employeeId)->orderBy('date', 'desc')->get();
$today = Carbon::today()->toDateString();
$todayAttendance = Attendance::where('employee_id', $employeeId)->where('date', $today)->first();
return Inertia::render('employee/attendances/index', [
'attendances' => $attendances,
'todayAttendance' => $todayAttendance,
]);
}
public function clockIn(Request $request)
{
$employee = $this->checkEmployee($request);
if (!$employee) {
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
}
$request->validate([
'latitude_in' => 'required|numeric',
'longitude_in' => 'required|numeric',
], [
'latitude_in.required' => 'Lokasi latitude wajib diisi.',
'latitude_in.numeric' => 'Lokasi latitude harus berupa angka.',
'longitude_in.required' => 'Lokasi longitude wajib diisi.',
'longitude_in.numeric' => 'Lokasi longitude harus berupa angka.',
]);
$employeeId = $employee->id;
$today = Carbon::today()->toDateString();
$existingAttendance = Attendance::where('employee_id', $employeeId)
->where('date', $today)
->first();
if ($existingAttendance) {
return back()->with('error', 'Anda sudah melakukan absen masuk hari ini.');
}
Attendance::create([
'employee_id' => $employeeId,
'date' => $today,
'check_in' => Carbon::now()->toTimeString(),
'latitude_in' => $request->latitude_in,
'longitude_in' => $request->longitude_in,
'status' => 'present',
]);
return back()->with('success', 'Berhasil Absen Masuk.');
}
public function clockOut(Request $request)
{
$employee = $this->checkEmployee($request);
if (!$employee) {
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
}
$request->validate([
'latitude_out' => 'required|numeric',
'longitude_out' => 'required|numeric',
], [
'latitude_out.required' => 'Lokasi latitude wajib diisi.',
'latitude_out.numeric' => 'Lokasi latitude harus berupa angka.',
'longitude_out.required' => 'Lokasi longitude wajib diisi.',
'longitude_out.numeric' => 'Lokasi longitude harus berupa angka.',
]);
$employeeId = $employee->id;
$today = Carbon::today()->toDateString();
$attendance = Attendance::where('employee_id', $employeeId)
->where('date', $today)
->first();
if (!$attendance || $attendance->check_out) {
return back()->with('error', 'Anda belum absen masuk atau sudah absen pulang hari ini.');
}
$attendance->update([
'check_out' => Carbon::now()->toTimeString(),
'latitude_out' => $request->latitude_out,
'longitude_out' => $request->longitude_out,
]);
return back()->with('success', 'Berhasil Absen Pulang.');
}
public function submitLeave(Request $request)
{
$employee = $this->checkEmployee($request);
if (!$employee) {
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
}
$request->validate([
'date' => 'required|date',
'notes' => 'required|string|min:5',
], [
'date.required' => 'Tanggal izin wajib diisi.',
'date.date' => 'Format tanggal tidak valid.',
'notes.required' => 'Keterangan izin wajib diisi.',
'notes.string' => 'Keterangan izin harus berupa teks.',
'notes.min' => 'Keterangan izin minimal 5 karakter.',
]);
Attendance::create([
'employee_id' => $employee->id,
'date' => $request->date,
'status' => 'leave',
'notes' => $request->notes,
]);
return back()->with('success', 'Izin berhasil diajukan.');
}
public function submitDispensation(Request $request)
{
$employee = $this->checkEmployee($request);
if (!$employee) {
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
}
$request->validate([
'latitude_in' => 'required|numeric',
'longitude_in' => 'required|numeric',
'notes' => 'required|string|min:5',
], [
'latitude_in.required' => 'Lokasi latitude wajib diisi.',
'latitude_in.numeric' => 'Lokasi latitude harus berupa angka.',
'longitude_in.required' => 'Lokasi longitude wajib diisi.',
'longitude_in.numeric' => 'Lokasi longitude harus berupa angka.',
'notes.required' => 'Keterangan dispensasi wajib diisi.',
'notes.string' => 'Keterangan dispensasi harus berupa teks.',
'notes.min' => 'Keterangan dispensasi minimal 5 karakter.',
]);
$now = Carbon::now();
Attendance::create([
'employee_id' => $employee->id,
'date' => $now->toDateString(),
'check_in' => $now->toTimeString(),
'latitude_in' => $request->latitude_in,
'longitude_in' => $request->longitude_in,
'status' => 'dispensation',
'notes' => $request->notes,
]);
return back()->with('success', 'Dispensasi berhasil diajukan.');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Employee;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use Illuminate\Http\Request;
use Carbon\Carbon;
use Inertia\Inertia;
class EmployeeDashboardController extends Controller
{
public function index(Request $request)
{
$employee = $request->user()->employee;
if (!$employee) {
return Inertia::render('employee/index', [
'stats' => null,
]);
}
$currentMonth = Carbon::now()->month;
$currentYear = Carbon::now()->year;
$attendances = Attendance::where('employee_id', $employee->id)
->whereMonth('date', $currentMonth)
->whereYear('date', $currentYear)
->get();
$presentCount = $attendances->where('status', 'present')->count();
$leaveCount = $attendances->where('status', 'leave')->count();
$dispensationCount = $attendances->where('status', 'dispensation')->count();
// Batas telat 08:00
$onTimeCount = $attendances->where('status', 'present')->filter(function ($att) {
return $att->check_in && $att->check_in <= '08:00:00';
})->count();
$lateCount = $attendances->where('status', 'present')->filter(function ($att) {
return $att->check_in && $att->check_in > '08:00:00';
})->count();
return Inertia::render('employee/index', [
'stats' => [
'present' => $presentCount,
'leave' => $leaveCount,
'dispensation' => $dispensationCount,
'on_time' => $onTimeCount,
'late' => $lateCount,
],
'employee' => $employee,
]);
}
}

View File

@ -45,6 +45,10 @@ public function share(Request $request): array
'role' => $request->user()->role,
] : null,
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
];
}
}

View File

@ -6,29 +6,77 @@
class StoreEmployeeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'nip' => 'required|string|unique:employees,nip',
'position' => 'required|string',
'status' => 'required|in:PKWT,PKWTT',
'join_date' => 'required|date',
// ── Identitas ────────────────────────────────────────────────
'name' => ['required', 'string', 'max:255', 'regex:/^[\pL\s]+$/u'],
'email' => ['nullable', 'email:rfc,dns', 'max:255', 'unique:employees,email'],
// ── Kepegawaian ──────────────────────────────────────────────
'nip' => ['required', 'digits:6', 'unique:employees,nip'],
// ── Data Pribadi ─────────────────────────────────────────────
'gender' => ['required', 'in:L,P'],
'place_of_birth'=> ['nullable', 'string', 'max:100'],
'birth_date' => ['required', 'date', 'before:today'],
'address' => ['nullable', 'string', 'max:500'],
'phone_number' => ['nullable', 'digits_between:8,15'],
// ── Jabatan & Departemen ─────────────────────────────────────
'department_id' => ['required', 'exists:departments,id'],
'position_id' => ['required', 'exists:positions,id'],
// ── Status & Tanggal ─────────────────────────────────────────
'status' => ['required', 'in:PKWT,PKWTT,Magang'],
'join_date' => ['required', 'date'],
];
}
public function messages(): array
{
return [
// Nama
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'name.regex' => 'Nama lengkap hanya boleh berisi huruf dan spasi.',
// Email
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'email.max' => 'Email maksimal 255 karakter.',
// NIP
'nip.required' => 'NIP wajib diisi.',
'nip.digits' => 'NIP harus tepat 6 digit angka.',
'nip.unique' => 'NIP sudah terdaftar pada karyawan lain.',
// Data Pribadi
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin tidak valid.',
'place_of_birth.max' => 'Tempat lahir maksimal 100 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'birth_date.before' => 'Tanggal lahir harus sebelum hari ini.',
'address.max' => 'Alamat maksimal 500 karakter.',
'phone_number.digits_between' => 'Nomor HP harus berupa angka, minimal 8 dan maksimal 15 digit.',
// Jabatan & Departemen
'department_id.required'=> 'Departemen wajib dipilih.',
'department_id.exists' => 'Departemen tidak ditemukan.',
'position_id.required' => 'Jabatan wajib dipilih.',
'position_id.exists' => 'Jabatan tidak ditemukan.',
// Status & Tanggal
'status.required' => 'Status kepegawaian wajib dipilih.',
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
'join_date.required' => 'Tanggal masuk wajib diisi.',
'join_date.date' => 'Format tanggal masuk tidak valid.',
];
}
}

View File

@ -3,36 +3,95 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEmployeeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$employeeId = $this->route('employee')->id;
$userId = $this->route('employee')->user_id;
$employee = $this->route('employee');
$employeeId = $employee->id;
$userId = $employee->user_id;
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $userId,
'nip' => 'required|string|unique:employees,nip,' . $employeeId,
'position' => 'required|string',
'status' => 'required|in:PKWT,PKWTT',
'join_date' => 'required|date',
// Password opsional saat update
'password' => 'nullable|string|min:8',
// ── Identitas ────────────────────────────────────────────────
'name' => ['required', 'string', 'max:255', 'regex:/^[\pL\s]+$/u'],
'email' => [
'nullable',
'email:rfc,dns',
'max:255',
// Abaikan email milik employee ini sendiri
Rule::unique('employees', 'email')->ignore($employeeId),
],
// ── Kepegawaian ──────────────────────────────────────────────
'nip' => [
'required',
'digits:6',
Rule::unique('employees', 'nip')->ignore($employeeId),
],
// ── Data Pribadi ─────────────────────────────────────────────
'gender' => ['required', 'in:L,P'],
'place_of_birth'=> ['nullable', 'string', 'max:100'],
'birth_date' => ['required', 'date', 'before:today'],
'address' => ['nullable', 'string', 'max:500'],
'phone_number' => ['nullable', 'digits_between:8,15'],
// ── Jabatan & Departemen ─────────────────────────────────────
'department_id' => ['required', 'exists:departments,id'],
'position_id' => ['required', 'exists:positions,id'],
// ── Status & Tanggal ─────────────────────────────────────────
'status' => ['required', 'in:PKWT,PKWTT,Magang'],
'join_date' => ['required', 'date'],
];
}
public function messages(): array
{
return [
// Nama
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'name.regex' => 'Nama lengkap hanya boleh berisi huruf dan spasi.',
// Email
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'email.max' => 'Email maksimal 255 karakter.',
// NIP
'nip.required' => 'NIP wajib diisi.',
'nip.digits' => 'NIP harus tepat 6 digit angka.',
'nip.unique' => 'NIP sudah terdaftar pada karyawan lain.',
// Data Pribadi
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin tidak valid.',
'place_of_birth.max' => 'Tempat lahir maksimal 100 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'birth_date.before' => 'Tanggal lahir harus sebelum hari ini.',
'address.max' => 'Alamat maksimal 500 karakter.',
'phone_number.digits_between' => 'Nomor HP harus berupa angka, minimal 8 dan maksimal 15 digit.',
// Jabatan & Departemen
'department_id.required'=> 'Departemen wajib dipilih.',
'department_id.exists' => 'Departemen tidak ditemukan.',
'position_id.required' => 'Jabatan wajib dipilih.',
'position_id.exists' => 'Jabatan tidak ditemukan.',
// Status & Tanggal
'status.required' => 'Status kepegawaian wajib dipilih.',
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
'join_date.required' => 'Tanggal masuk wajib diisi.',
'join_date.date' => 'Format tanggal masuk tidak valid.',
];
}
}

30
app/Models/Attendance.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Attendance extends Model
{
use HasFactory;
protected $fillable = [
'employee_id',
'date',
'check_in',
'check_out',
'latitude_in',
'longitude_in',
'latitude_out',
'longitude_out',
'status',
'notes',
];
public function employee(): BelongsTo
{
return $this->belongsTo(Employee::class);
}
}

View File

@ -10,9 +10,10 @@ class Employee extends Model
use HasFactory;
protected $fillable = [
'user_id',
'user_id', // diisi oleh UserController saat akun digenerate
'nip',
'name',
'email',
'gender',
'place_of_birth',
'birth_date',
@ -21,13 +22,13 @@ class Employee extends Model
'department_id',
'position_id',
'status',
'join_date'
'join_date',
];
// Relasi ke User
// Relasi ke User (nullable: karyawan boleh belum punya akun)
public function user()
{
return $this->belongsTo(User::class);
return $this->belongsTo(User::class)->withDefault();
}
// Relasi ke Departemen
@ -41,4 +42,9 @@ public function position()
{
return $this->belongsTo(Position::class);
}
public function attendances()
{
return $this->hasMany(Attendance::class);
}
}

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

@ -0,0 +1,31 @@
<?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',
];
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

@ -2,7 +2,6 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -10,26 +9,16 @@
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'role',
'employee_id',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'two_factor_secret',
@ -42,22 +31,23 @@ public function employee()
return $this->hasOne(Employee::class);
}
// employee_id di kolom users untuk relasi One-to-One langsung
public function employeeProfile()
{
return $this->belongsTo(Employee::class, 'employee_id');
}
public function hasRole($role)
{
return $this->role === $role;
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at'=> 'datetime',
];
}
}

View File

@ -14,6 +14,7 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [

View File

@ -9,7 +9,7 @@
],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.3",
"inertiajs/inertia-laravel": "^2.0",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// No-op
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// No-op
}
};

View File

@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
// No-op
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// No-op
}
};

View File

@ -15,7 +15,7 @@ public function up()
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
// Data Pribadi
$table->string('nip')->unique();
$table->string('name');
$table->enum('gender', ['L', 'P']);
@ -24,11 +24,11 @@ public function up()
$table->text('address')->nullable();
$table->string('phone_number')->nullable();
// Data Pekerjaan
$table->foreignId('department_id')->constrained()->onDelete('restrict');
$table->foreignId('position_id')->constrained()->onDelete('restrict');
// Status & Tanggal
$table->enum('status', ['PKWT', 'PKWTT', 'Magang']);
$table->date('join_date');

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');
$table->unsignedBigInteger('basic_salary');
$table->json('details')->nullable();
$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,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('employee_id')
->nullable()
->unique()
->constrained('employees')
->nullOnDelete()
->after('id');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['employee_id']);
$table->dropColumn('employee_id');
});
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('payrolls', function (Blueprint $table) {
$table->unique(['employee_id', 'period'], 'payrolls_employee_period_unique');
});
}
public function down(): void
{
Schema::table('payrolls', function (Blueprint $table) {
$table->dropUnique('payrolls_employee_period_unique');
});
}
};

View File

@ -0,0 +1,16 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// No-op
}
public function down(): void
{
// No-op
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('attendances', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->time('check_in')->nullable();
$table->time('check_out')->nullable();
$table->decimal('latitude_in', 10, 8)->nullable();
$table->decimal('longitude_in', 11, 8)->nullable();
$table->decimal('latitude_out', 10, 8)->nullable();
$table->decimal('longitude_out', 11, 8)->nullable();
$table->enum('status', ['present', 'leave', 'dispensation'])->default('present');
$table->text('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('attendances');
}
};

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Ubah kolom user_id menjadi nullable agar data karyawan
* dapat disimpan tanpa harus memiliki akun User terlebih dahulu.
* Pembuatan akun User dilakukan secara manual oleh admin
* melalui menu Manajemen Pengguna.
*/
public function up(): void
{
Schema::table('employees', function (Blueprint $table) {
// Drop foreign key constraint lama (NOT NULL + cascade delete)
$table->dropForeign(['user_id']);
// Ubah kolom menjadi nullable dan pasang kembali foreign key
// dengan nullOnDelete agar baris employee tidak ikut terhapus
// jika akun User-nya dihapus.
$table->foreignId('user_id')
->nullable()
->change();
$table->foreign('user_id')
->references('id')
->on('users')
->nullOnDelete();
});
}
/**
* Rollback: kembalikan user_id ke NOT NULL dengan cascade delete.
*/
public function down(): void
{
Schema::table('employees', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->foreignId('user_id')
->nullable(false)
->change();
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Tambahkan kolom email ke tabel employees.
* Sebelumnya email hanya disimpan di tabel users, namun sejak
* pembuatan akun User dipisahkan dari proses tambah karyawan,
* email perlu ikut disimpan di tabel employees sebagai data kontak.
*/
public function up(): void
{
Schema::table('employees', function (Blueprint $table) {
// Ditempatkan setelah kolom 'name', nullable karena
// data karyawan lama mungkin belum memiliki email.
$table->string('email')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('employees', function (Blueprint $table) {
$table->dropColumn('email');
});
}
};

View File

@ -9,8 +9,10 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->call([
UserSeeder::class,
MasterDataSeeder::class,
UserSeeder::class,
MasterDataSeeder::class,
EmployeeSeeder::class,
PayrollSeeder::class,
]);
}
}

View File

@ -0,0 +1,70 @@
<?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;
class EmployeeSeeder extends Seeder
{
public function run(): void
{
$depts = Department::pluck('id', 'name');
$positions = Position::pluck('id', 'name');
$employees = [
[
'user' => ['name' => 'Jono Joni', 'email' => 'jono12@gmail.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2023001', 'name' => 'Jono Joni', 'gender' => 'L', 'place_of_birth' => 'Jakarta', 'birth_date' => '1992-05-14', 'address' => 'Jl. Merdeka No.12, Jakarta Pusat', 'phone_number' => '081234567890', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Senior Developer'], 'status' => 'PKWTT', 'join_date' => '2023-01-15'],
],
[
'user' => ['name' => 'Budi Santoso', 'email' => 'budi.santoso12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2023002', 'name' => 'Budi Santoso', 'gender' => 'L', 'place_of_birth' => 'Bandung', 'birth_date' => '1995-08-22', 'address' => 'Jl. Sukajadi No.45, Bandung', 'phone_number' => '082345678901', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'PKWT', 'join_date' => '2023-03-01'],
],
[
'user' => ['name' => 'Rizky Firmansyah', 'email' => 'rizky.firmansyah12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2024001', 'name' => 'Rizky Firmansyah', 'gender' => 'L', 'place_of_birth' => 'Surabaya', 'birth_date' => '1998-11-30', 'address' => 'Jl. Pemuda No.7, Surabaya', 'phone_number' => '083456789012', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'Magang', 'join_date' => '2024-02-01'],
],
[
'user' => ['name' => 'Dewi Rahayu', 'email' => 'dewi.rahayu12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2022001', 'name' => 'Dewi Rahayu', 'gender' => 'P', 'place_of_birth' => 'Yogyakarta', 'birth_date' => '1990-03-10', 'address' => 'Jl. Malioboro No.88, Yogyakarta', 'phone_number' => '084567890123', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['Manager'], 'status' => 'PKWTT', 'join_date' => '2022-06-01'],
],
[
'user' => ['name' => 'Anisa Putri', 'email' => 'anisa.putri12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2023003', 'name' => 'Anisa Putri', 'gender' => 'P', 'place_of_birth' => 'Semarang', 'birth_date' => '1997-07-19', 'address' => 'Jl. Pahlawan No.3, Semarang', 'phone_number' => '085678901234', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['HR Specialist'], 'status' => 'PKWTT', 'join_date' => '2023-07-01'],
],
[
'user' => ['name' => 'Hendra Kurniawan', 'email' => 'hendra.kurniawan12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2021001', 'name' => 'Hendra Kurniawan', 'gender' => 'L', 'place_of_birth' => 'Medan', 'birth_date' => '1988-12-05', 'address' => 'Jl. Sudirman No.22, Medan', 'phone_number' => '086789012345', 'department_id' => $depts['Finance'], 'position_id' => $positions['Finance Analyst'], 'status' => 'PKWTT', 'join_date' => '2021-04-01'],
],
[
'user' => ['name' => 'Sari Wulandari', 'email' => 'sari.wulandari12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2023004', 'name' => 'Sari Wulandari', 'gender' => 'P', 'place_of_birth' => 'Solo', 'birth_date' => '1996-02-28', 'address' => 'Jl. Brigjen Katamso No.5, Solo', 'phone_number' => '087890123456', 'department_id' => $depts['Finance'], 'position_id' => $positions['Staff Admin'], 'status' => 'PKWT', 'join_date' => '2023-09-01'],
],
[
'user' => ['name' => 'Fajar Nugroho', 'email' => 'fajar.nugroho12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2022002', 'name' => 'Fajar Nugroho', 'gender' => 'L', 'place_of_birth' => 'Makassar', 'birth_date' => '1993-09-15', 'address' => 'Jl. Sultan Hasanuddin No.10, Makassar', 'phone_number' => '088901234567', 'department_id' => $depts['Marketing'], 'position_id' => $positions['Marketing Staff'], 'status' => 'PKWTT', 'join_date' => '2022-11-01'],
],
[
'user' => ['name' => 'Linda Permatasari', 'email' => 'linda.permata12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
'employee' => ['nip' => '2024002', 'name' => 'Linda Permatasari', 'gender' => 'P', 'place_of_birth' => 'Palembang', 'birth_date' => '1999-04-20', 'address' => 'Jl. Demang Lebar Daun No.8, Palembang', 'phone_number' => '089012345678', 'department_id' => $depts['Operations'], 'position_id' => $positions['Operations Staff'], 'status' => 'PKWT', 'join_date' => '2024-01-10'],
],
[
'user' => ['name' => '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'],
],
];
foreach ($employees as $data) {
$user = User::create($data['user']);
$employee = Employee::create(array_merge($data['employee'], ['user_id' => $user->id]));
$user->update(['employee_id' => $employee->id]);
}
$this->command->info('EmployeeSeeder: ' . count($employees) . ' karyawan berhasil dibuat.');
}
}

View File

@ -10,34 +10,39 @@ 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,172 @@
<?php
namespace Database\Seeders;
use App\Models\Employee;
use App\Models\Payroll;
use Illuminate\Database\Seeder;
class PayrollSeeder extends Seeder
{
public function run(): void
{
// Kalkulasi net_salary konsisten dengan logika di PayrollController
$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);
};
// Eager pluck: 1 query untuk semua employee
$emp = Employee::pluck('id', 'name');
$bulanLalu = now()->subMonth()->format('Y-m');
$bulanIni = now()->format('Y-m');
$records = [];
if (isset($emp['Jono Joni'])) {
$basic = 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' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $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' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
}
if (isset($emp['Budi Santoso'])) {
$basic = 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' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $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' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
}
if (isset($emp['Rizky Firmansyah'])) {
$basic = 5_500_000;
$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' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
}
if (isset($emp['Dewi Rahayu'])) {
$basic = 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' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $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' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
}
if (isset($emp['Anisa Putri'])) {
$basic = 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' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
}
if (isset($emp['Hendra Kurniawan'])) {
$basic = 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' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
$detailIni = $detailLalu;
$records[] = ['employee_id' => $emp['Hendra Kurniawan'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
}
if (isset($emp['Sari Wulandari'])) {
$basic = 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' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
}
if (isset($emp['Fajar Nugroho'])) {
$basic = 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' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $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' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
}
if (isset($emp['Agus Prasetyo'])) {
$basic = 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' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
}
foreach ($records as $data) {
Payroll::create($data);
}
$this->command->info('PayrollSeeder: ' . count($records) . ' slip gaji berhasil dibuat.');
}
}

View File

@ -6,66 +6,29 @@
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
/**
* UserSeeder: Hanya membuat akun-akun Admin.
* Akun Employee dibuat oleh EmployeeSeeder (bersama data karyawan).
*/
class UserSeeder extends Seeder
{
public function run(): void
{
// Akun Admin Utama
User::create([
'name' => 'admin',
'email' => 'admin@gmail.com',
'name' => 'Admin HRIS',
'email' => 'admin@hris.com',
'password' => Hash::make('password'),
'role' => 'admin',
'role' => 'admin',
]);
User::create([
'name' => 'Jono Joni',
'email' => 'jono@gmail.com',
'name' => 'Siti Nurhaliza',
'email' => 'siti.admin@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Siti Nurhaliza',
'email' => 'siti.admin@hris.com',
'password' => Hash::make('password'),
'role' => 'admin',
]);
// --- Akun Karyawan (Employee) ---
User::create([
'name' => 'Budi Santoso',
'email' => 'budi.santoso@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Dewi Rahayu',
'email' => 'dewi.rahayu@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Rizky Firmansyah',
'email' => 'rizky.firmansyah@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Anisa Putri',
'email' => 'anisa.putri@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Hendra Kurniawan',
'email' => 'hendra.kurniawan@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
'role' => 'admin',
]);
$this->command->info('UserSeeder: Akun admin berhasil dibuat.');
}
}

View File

@ -0,0 +1,163 @@
<mxfile host="app.diagrams.net" modified="2026-04-21T00:00:00.000Z" agent="GitHub Copilot" version="24.7.17" type="device">
<diagram id="flowchart-aplikasi" name="Flowchart Aplikasi HR Management">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2200" pageHeight="1400" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="User membuka aplikasi" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="40" y="60" width="160" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="/" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="250" y="60" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="4" value="Redirect ke /login" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="420" y="60" width="170" height="60" as="geometry"/>
</mxCell>
<mxCell id="5" value="Login Fortify" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="630" y="60" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="6" value="Login valid?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="825" y="50" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="7" value="Email verified?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="1000" y="50" width="140" height="80" as="geometry"/>
</mxCell>
<mxCell id="8" value="Role user" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="1200" y="50" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="9" value="Halaman verifikasi email" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="995" y="180" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="10" value="/dashboard" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="1200" y="180" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="11" value="/employee/index" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="1200" y="300" width="140" height="60" as="geometry"/>
</mxCell>
<mxCell id="12" value="DashboardController@index" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1400" y="180" width="190" height="60" as="geometry"/>
</mxCell>
<mxCell id="13" value="Role admin?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="1625" y="170" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="14" value="Dashboard admin" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;" vertex="1" parent="1">
<mxGeometry x="1800" y="180" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="15" value="Redirect ke employee.index" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1620" y="300" width="180" height="60" as="geometry"/>
</mxCell>
<mxCell id="16" value="Statistik, grafik, latest employees" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1980" y="180" width="190" height="60" as="geometry"/>
</mxCell>
<mxCell id="17" value="Departments" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1800" y="320" width="120" height="50" as="geometry"/>
</mxCell>
<mxCell id="18" value="Positions" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1960" y="320" width="110" height="50" as="geometry"/>
</mxCell>
<mxCell id="19" value="Employees" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="2110" y="320" width="110" height="50" as="geometry"/>
</mxCell>
<mxCell id="20" value="Attendance" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1800" y="410" width="120" height="50" as="geometry"/>
</mxCell>
<mxCell id="21" value="Payrolls" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1960" y="410" width="110" height="50" as="geometry"/>
</mxCell>
<mxCell id="22" value="Users" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="2110" y="410" width="110" height="50" as="geometry"/>
</mxCell>
<mxCell id="23" value="Settings" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1960" y="500" width="110" height="50" as="geometry"/>
</mxCell>
<mxCell id="24" value="Profile / Password / Appearance / 2FA" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="1620" y="500" width="260" height="60" as="geometry"/>
</mxCell>
<mxCell id="25" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="26" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="3" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="27" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="4" target="5">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="28" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="5" target="6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="29" value="ya" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="6" target="7">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="30" value="tidak" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="6" target="5">
<mxGeometry relative="1" as="geometry">
<mxPoint x="800" y="140" as="sourcePoint"/>
<mxPoint x="700" y="140" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="31" value="tidak" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="7" target="9">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="32" value="ya" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="7" target="8">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="33" value="admin" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="8" target="10">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="34" value="employee" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="8" target="11">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1280" y="150"/>
<mxPoint x="1280" y="270"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="35" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="10" target="12">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="36" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="12" target="13">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="37" value="ya" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="13" target="14">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="38" value="tidak" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="13" target="15">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="39" value="menampilkan ringkasan" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="14" target="16">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="40" value="menu admin" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="14" target="17">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="41" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="17" target="18">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="42" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="18" target="19">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="43" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="19" target="20">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="44" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="20" target="21">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="45" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="21" target="22">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="46" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="22" target="23">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="47" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="23" target="24">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

149
docs/flowchart-aplikasi.md Normal file
View File

@ -0,0 +1,149 @@
# Flowchart Aplikasi HR Management
Dokumen ini merangkum alur aplikasi berdasarkan route, controller, dan fitur yang saat ini aktif di project.
## Alur Utama
```mermaid
flowchart TD
A[User membuka aplikasi] --> B[/ /]
B --> C[Redirect ke /login]
C --> D[Halaman login Fortify]
D --> E{Login valid?}
E -- Tidak --> D
E -- Ya --> F{Email sudah verified?}
F -- Tidak --> G[Halaman verifikasi email]
G --> D
F -- Ya --> H{Role user}
H -- admin --> I[/dashboard]
H -- employee --> J[/employee/index]
I --> K[DashboardController@index]
K --> L{Role admin?}
L -- Tidak --> J
L -- Ya --> M[Dashboard admin]
M --> N[Statistik total karyawan, departemen, jabatan]
M --> O[Grafik gender]
M --> P[Grafik departemen]
M --> Q[Daftar 5 karyawan terbaru]
M --> R[Menu admin]
R --> S[Departments]
R --> T[Positions]
R --> U[Employees]
R --> V[Attendance]
R --> W[Payrolls]
R --> X[Users]
R --> Y[Settings]
J --> Z[Halaman employee/index]
Z --> Y
```
## Detail Alur per Modul
```mermaid
flowchart TD
subgraph AUTH[Autentikasi]
A1[Root /] --> A2[Redirect ke /login]
A2 --> A3[Login / Register / Forgot Password / Reset Password]
A3 --> A4{Login berhasil?}
A4 -- Tidak --> A3
A4 -- Ya --> A5{Email verified?}
A5 -- Tidak --> A6[Verify email]
A6 --> A3
A5 -- Ya --> A7[Home /dashboard]
end
subgraph DASH[Dashboard]
D1[/dashboard/] --> D2[DashboardController@index]
D2 --> D3[Jika role bukan admin, redirect ke employee.index]
D2 --> D4[Jika admin, tampilkan ringkasan HR]
D4 --> D5[Total employee, department, position]
D4 --> D6[Gender chart]
D4 --> D7[Department chart]
D4 --> D8[Latest employees]
end
subgraph DEPT[Departments]
E1[Index] --> E2[Create]
E2 --> E3[Store]
E1 --> E4[Edit]
E4 --> E5[Update]
E1 --> E6[Destroy]
E6 --> E7{Masih ada karyawan?}
E7 -- Ya --> E8[Gagal hapus]
E7 -- Tidak --> E9[Departemen terhapus]
end
subgraph POS[Positions]
P1[Index] --> P2[Create]
P2 --> P3[Store]
P1 --> P4[Edit]
P4 --> P5[Update]
P1 --> P6[Destroy]
P6 --> P7{Masih ada karyawan?}
P7 -- Ya --> P8[Gagal hapus]
P7 -- Tidak --> P9[Jabatan terhapus]
end
subgraph EMP[Employees]
M1[Index + filter search dan department]
M1 --> M2[Create]
M2 --> M3[Isi biodata, departemen, jabatan, akun login]
M3 --> M4[Store]
M4 --> M5[Create user]
M5 --> M6[Create employee]
M1 --> M7[Edit]
M7 --> M8[Update user dan employee]
M1 --> M9[Destroy]
M9 --> M10[Delete user terkait]
end
subgraph ATT[Attendance]
T1[Index + filter date, status, department, search]
T1 --> T2[Create]
T2 --> T3[Pilih employee]
T3 --> T4[Store]
T4 --> T5[Validasi unik per employee per tanggal]
end
subgraph PAY[Payrolls]
R1[Index payroll]
R1 --> R2[Create]
R2 --> R3[Pilih employee dan data salary]
R3 --> R4[Store]
R4 --> R5[Hitung net salary]
R5 --> R6[Simpan status pending]
R1 --> R7[Show detail payroll]
end
subgraph USERS[Users]
U1[Index user]
U1 --> U2[Create]
U2 --> U3[Pilih employee yang belum punya akun]
U3 --> U4[Store]
U4 --> U5[Create user + link employee_id]
U1 --> U6[Update role]
end
subgraph SETTING[Settings]
S1[Profile] --> S2[Update profile]
S1 --> S3[Delete profile]
S4[Password] --> S5[Update password]
S6[Appearance] --> S7[Theme page]
S8[Two factor] --> S9[Two-factor page]
end
```
## Ringkasan Urutan Penggunaan
1. User membuka aplikasi, lalu diarahkan ke halaman login.
2. User login melalui Fortify.
3. Sistem mengecek verifikasi email.
4. Jika role admin, user masuk ke dashboard admin dan mengelola master data, absensi, payroll, dan user akun.
5. Jika role employee, user diarahkan ke halaman employee.
6. Seluruh user tetap bisa mengakses menu settings sesuai autentikasi dan verifikasi.
Jika kamu mau, saya bisa lanjut ubah flowchart ini menjadi versi gambar yang lebih rapi untuk presentasi, atau saya buatkan versi khusus per modul dalam satu diagram terpisah.

166
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.11",
"@types/leaflet": "^1.9.21",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
@ -30,17 +31,21 @@
"globals": "^15.14.0",
"input-otp": "^1.4.2",
"laravel-vite-plugin": "^2.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.475.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2",
"use-debounce": "^10.1.0",
"vite": "^7.0.4"
"vite": "^7.0.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
@ -4673,6 +4678,17 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@react-stately/flags": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
@ -5502,6 +5518,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -5516,6 +5538,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
@ -6146,6 +6177,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -6557,6 +6597,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -6620,6 +6673,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -6687,6 +6749,18 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -7813,6 +7887,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -8794,6 +8877,12 @@
"vite": "^7.0.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -9947,6 +10036,20 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@ -10498,6 +10601,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -10507,6 +10620,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable-hash-x": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
@ -11342,6 +11467,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -11369,6 +11512,27 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -43,6 +43,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.11",
"@types/leaflet": "^1.9.21",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
@ -52,17 +53,21 @@
"globals": "^15.14.0",
"input-otp": "^1.4.2",
"laravel-vite-plugin": "^2.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.475.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2",
"use-debounce": "^10.1.0",
"vite": "^7.0.4"
"vite": "^7.0.4",
"xlsx": "^0.18.5"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

BIN
public/assets/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

5
railpack.json Normal file
View File

@ -0,0 +1,5 @@
{
"php": {
"version": "8.3"
}
}

View File

@ -0,0 +1,96 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { MapPin } from 'lucide-react';
// Perbaikan issue icon marker Leaflet di React
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
});
interface AttendanceMapModalProps {
latitudeIn?: string | null;
longitudeIn?: string | null;
latitudeOut?: string | null;
longitudeOut?: string | null;
employeeName: string;
date: string;
}
export function AttendanceMapModal({ latitudeIn, longitudeIn, latitudeOut, longitudeOut, employeeName, date }: AttendanceMapModalProps) {
const hasIn = latitudeIn && longitudeIn;
const hasOut = latitudeOut && longitudeOut;
const hasData = hasIn || hasOut;
if (!hasData) {
return (
<span className="text-xs text-muted-foreground italic flex items-center gap-1">
<MapPin className="h-3 w-3" /> Tidak ada data lokasi
</span>
);
}
const posIn: [number, number] | null = hasIn ? [parseFloat(latitudeIn as string), parseFloat(longitudeIn as string)] : null;
const posOut: [number, number] | null = hasOut ? [parseFloat(latitudeOut as string), parseFloat(longitudeOut as string)] : null;
// Default center ke check in, atau check out
const center = posIn || posOut || [-6.200000, 106.816666];
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5 h-8">
<MapPin className="h-3.5 w-3.5 text-blue-600" /> Lihat Peta
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Detail Lokasi Absensi</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
<span className="font-medium text-foreground">{employeeName}</span> {date}
</p>
</DialogHeader>
<div className="h-[400px] w-full rounded-md overflow-hidden border mt-2">
<MapContainer center={center} zoom={16} style={{ height: '100%', width: '100%', zIndex: 1 }}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/>
{posIn && (
<Marker position={posIn}>
<Popup>
<div className="font-semibold text-sm mb-1">📍 Lokasi Clock In</div>
<div className="text-xs text-muted-foreground">
Lat: {posIn[0]}<br />
Lng: {posIn[1]}
</div>
</Popup>
</Marker>
)}
{posOut && (
<Marker position={posOut}>
<Popup>
<div className="font-semibold text-sm mb-1">📍 Lokasi Clock Out</div>
<div className="text-xs text-muted-foreground">
Lat: {posOut[0]}<br />
Lng: {posOut[1]}
</div>
</Popup>
</Marker>
)}
</MapContainer>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -2,12 +2,6 @@ import type { SVGAttributes } from 'react';
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
return (
<svg {...props} viewBox="0 0 40 42" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.2 5.63325L8.6 0.855469L0 5.63325V32.1434L16.2 41.1434L32.4 32.1434V23.699L40 19.4767V9.85547L31.4 5.07769L22.8 9.85547V18.2999L17.2 21.411V5.63325ZM38 18.2999L32.4 21.411V15.2545L38 12.1434V18.2999ZM36.9409 10.4439L31.4 13.5221L25.8591 10.4439L31.4 7.36561L36.9409 10.4439ZM24.8 18.2999V12.1434L30.4 15.2545V21.411L24.8 18.2999ZM23.8 20.0323L29.3409 23.1105L16.2 30.411L10.6591 27.3328L23.8 20.0323ZM7.6 27.9212L15.2 32.1434V38.2999L2 30.9666V7.92116L7.6 11.0323V27.9212ZM8.6 9.29991L3.05913 6.22165L8.6 3.14339L14.1409 6.22165L8.6 9.29991ZM30.4 24.8101L17.2 32.1434V38.2999L30.4 30.9666V24.8101ZM9.6 11.0323L15.2 7.92117V22.5221L9.6 25.6333V11.0323Z"
/>
</svg>
<img src="/assets/logo-clear.png" alt="Logo" className="w-8 h-8"/>
);
}

View File

@ -3,12 +3,12 @@ import AppLogoIcon from './app-logo-icon';
export default function AppLogo() {
return (
<>
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
<div className="flex aspect-square size-8 items-center justify-center">
<AppLogoIcon className="size-3" />
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
SDM App
HRIS App
</span>
</div>
</>

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, CalendarClock } from 'lucide-react';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
import {
@ -42,12 +42,29 @@ export function AppSidebar() {
href: '/admin/positions',
icon: Briefcase,
},
{
title: 'Manajemen Absensi',
href: '/admin/attendance',
icon: CalendarClock,
},
{
title: 'Manajemen Gaji',
href: '/admin/payrolls',
icon: Wallet,
},
{
title: 'Manajemen Pengguna',
href: '/admin/users',
icon: SquareUser,
},
] : []),
...(userRole === 'employee' ? [
{
title: 'Absensi',
href: '/employee/attendances',
icon: CalendarClock,
},
] : []),
];
return (

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

@ -0,0 +1,89 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -3,6 +3,19 @@ import { AppShell } from '@/components/app-shell';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebarHeader } from '@/components/app-sidebar-header';
import type { AppLayoutProps } from '@/types';
import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
import { Toaster, toast } from 'sonner';
/** Auto-show Laravel flash messages as Sonner toasts on every page visit */
function FlashToast() {
const { flash } = usePage<{ flash?: { success?: string; error?: string } }>().props;
useEffect(() => {
if (flash?.success) toast.success(flash.success);
if (flash?.error) toast.error(flash.error);
}, [flash]);
return null;
}
export default function AppSidebarLayout({
children,
@ -13,8 +26,10 @@ export default function AppSidebarLayout({
<AppSidebar />
<AppContent variant="sidebar" className="overflow-x-hidden">
<AppSidebarHeader breadcrumbs={breadcrumbs} />
<FlashToast />
{children}
</AppContent>
<Toaster richColors position="top-right" />
</AppShell>
);
}

View File

@ -0,0 +1,78 @@
import React from 'react';
import { Toaster } from 'sonner';
import { Head, Link, usePage } from '@inertiajs/react';
import { LogOut, Home, CalendarClock, User, Menu } from 'lucide-react';
interface EmployeeLayoutProps {
children: React.ReactNode;
title?: string;
}
export default function EmployeeLayout({ children, title }: EmployeeLayoutProps) {
const user = (usePage<any>().props.auth as any).user;
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900 pb-20 md:pb-0">
{title && <Head title={title} />}
<Toaster position="top-center" richColors />
<nav className="hidden md:block bg-white border-b border-slate-200 sticky top-0 z-50 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center gap-8">
<div className="flex-shrink-0 flex items-center gap-3">
<img src="/assets/logo-clear.png" alt="Logo" className="h-8 w-auto object-contain" onError={(e) => { e.currentTarget.style.display='none'; }} />
<span className="font-bold text-xl text-sky-800 tracking-tight hidden lg:block">HRIS Portal</span>
</div>
<div className="flex space-x-1">
<Link href="/employee/index" className="px-3 py-2 rounded-md text-sm font-medium text-slate-600 hover:text-sky-600 hover:bg-sky-50 transition-colors flex items-center gap-2">
<Home className="w-4 h-4" /> Beranda
</Link>
<Link href="/employee/attendances" className="px-3 py-2 rounded-md text-sm font-medium text-slate-600 hover:text-sky-600 hover:bg-sky-50 transition-colors flex items-center gap-2">
<CalendarClock className="w-4 h-4" /> Absensi
</Link>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-sm font-medium text-slate-700 hidden sm:block">
Halo, {user.name}
</div>
<Link href="/logout" method="post" as="button" className="p-2 text-slate-500 hover:text-red-600 transition-colors rounded-full hover:bg-slate-100">
<LogOut className="w-5 h-5" />
</Link>
</div>
</div>
</div>
</nav>
<header className="md:hidden bg-white border-b border-slate-200 sticky top-0 z-40 px-4 h-14 flex items-center justify-between">
<img src="/assets/logo-clear.png" alt="Logo" className="h-7 w-auto object-contain" onError={(e) => { e.currentTarget.style.display='none'; }} />
<div className="font-semibold text-slate-800 text-sm">HRIS Portal</div>
<div className="w-7 h-7"></div>
</header>
<main className="w-full relative">
{children}
</main>
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-slate-200 px-6 py-2 flex justify-between items-center pb-safe shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<Link href="/employee/index" className="flex flex-col items-center gap-1 text-slate-400 hover:text-sky-500 focus:text-sky-500 transition-colors p-1">
<Home className="w-5 h-5" />
<span className="text-[10px] font-medium">Beranda</span>
</Link>
<Link href="/employee/attendances" className="flex flex-col items-center gap-1 text-slate-400 hover:text-sky-500 focus:text-sky-500 transition-colors p-1">
<CalendarClock className="w-5 h-5" />
<span className="text-[10px] font-medium">Absensi</span>
</Link>
<Link href="/profile" className="flex flex-col items-center gap-1 text-slate-400 hover:text-sky-500 focus:text-sky-500 transition-colors p-1">
<User className="w-5 h-5" />
<span className="text-[10px] font-medium">Profil</span>
</Link>
<Link href="/logout" method="post" as="button" className="flex flex-col items-center gap-1 text-slate-400 hover:text-red-500 focus:text-red-500 transition-colors p-1">
<LogOut className="w-5 h-5" />
<span className="text-[10px] font-medium">Keluar</span>
</Link>
</nav>
</div>
);
}

View File

@ -7,7 +7,6 @@ import { useCurrentUrl } from '@/hooks/use-current-url';
import { cn, toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance';
import { edit } from '@/routes/profile';
import { show } from '@/routes/two-factor';
import { edit as editPassword } from '@/routes/user-password';
import type { NavItem } from '@/types';
@ -22,11 +21,7 @@ const sidebarNavItems: NavItem[] = [
href: editPassword(),
icon: null,
},
{
title: 'Two-Factor Auth',
href: show(),
icon: null,
},
// Two-Factor Auth nav item is intentionally hidden
{
title: 'Appearance',
href: editAppearance(),

View File

@ -0,0 +1,188 @@
import { Head, Link, useForm } 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 { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
interface Employee {
id: number;
name: string;
nip: string;
department: string | null;
}
interface PageProps {
employees: Employee[];
}
export default function Create({ employees }: PageProps) {
const { data, setData, post, processing, errors, transform } = useForm({
employee_id: '',
date: '',
shift: '',
status: 'hadir',
check_in: '',
check_out: '',
notes: '',
});
const selectedEmployee = employees.find((employee) => String(employee.id) === data.employee_id) ?? null;
const submit = (e: React.FormEvent) => {
e.preventDefault();
transform((formData) => ({
...formData,
check_in: formData.check_in || null,
check_out: formData.check_out || null,
notes: formData.notes || null,
}));
post('/admin/attendance');
};
return (
<AppLayout>
<Head title="Input Absensi" />
<div className="p-4 md:p-8 max-w-3xl mx-auto">
<Button variant="outline" asChild className="mb-6">
<Link href="/admin/attendance">Kembali</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>Input Absensi Karyawan</CardTitle>
<p className="text-sm text-muted-foreground">Isi data absensi harian untuk 1 karyawan per tanggal.</p>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<Label>Karyawan</Label>
<Select value={data.employee_id} onValueChange={(value) => setData('employee_id', value)}>
<SelectTrigger>
<SelectValue placeholder="Pilih karyawan" />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={String(employee.id)}>
{employee.name} ({employee.nip})
</SelectItem>
))}
</SelectContent>
</Select>
{errors.employee_id && <p className="text-xs text-red-500">{errors.employee_id}</p>}
</div>
{selectedEmployee && (
<div className="rounded-md border bg-muted/40 px-4 py-3 text-sm">
<span className="text-muted-foreground">Departemen: </span>
<span className="font-medium">{selectedEmployee.department || '-'}</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tanggal</Label>
<Input
type="date"
value={data.date}
onChange={(e) => setData('date', e.target.value)}
/>
{errors.date && <p className="text-xs text-red-500">{errors.date}</p>}
</div>
<div className="space-y-2">
<Label>Shift</Label>
<Select value={data.shift} onValueChange={(value) => setData('shift', value)}>
<SelectTrigger>
<SelectValue placeholder="Pilih shift" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Pagi">Pagi</SelectItem>
<SelectItem value="Siang">Siang</SelectItem>
<SelectItem value="Malam">Malam</SelectItem>
</SelectContent>
</Select>
{errors.shift && <p className="text-xs text-red-500">{errors.shift}</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Jam Masuk</Label>
<Input
type="datetime-local"
value={data.check_in}
onChange={(e) => setData('check_in', e.target.value)}
/>
{errors.check_in && <p className="text-xs text-red-500">{errors.check_in}</p>}
</div>
<div className="space-y-2">
<Label>Jam Pulang</Label>
<Input
type="datetime-local"
value={data.check_out}
onChange={(e) => setData('check_out', e.target.value)}
/>
{errors.check_out && <p className="text-xs text-red-500">{errors.check_out}</p>}
</div>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={data.status} onValueChange={(value) => setData('status', value)}>
<SelectTrigger>
<SelectValue placeholder="Pilih status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hadir">Hadir</SelectItem>
<SelectItem value="izin">Izin</SelectItem>
<SelectItem value="sakit">Sakit</SelectItem>
<SelectItem value="alpha">Alpha</SelectItem>
</SelectContent>
</Select>
{errors.status && <p className="text-xs text-red-500">{errors.status}</p>}
</div>
<div className="space-y-2">
<Label>Catatan</Label>
<Input
value={data.notes}
onChange={(e) => setData('notes', e.target.value)}
placeholder="Opsional"
/>
{errors.notes && <p className="text-xs text-red-500">{errors.notes}</p>}
</div>
<Separator />
<div className="flex gap-3">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan Absensi'}
</Button>
<Button type="button" variant="ghost" asChild>
<Link href="/admin/attendance">Batal</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,229 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Head, Link, router, usePage } from '@inertiajs/react';
import React, { useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
interface Attendance {
id: number;
date: string;
shift: 'Pagi' | 'Siang' | 'Malam';
status: 'hadir' | 'izin' | 'sakit' | 'alpha';
check_in: string | null;
check_out: string | null;
notes: string | null;
employee: {
id: number;
name: string;
nip: string;
department: {
id: number;
name: string;
} | null;
};
}
interface PageProps {
attendances: {
data: Attendance[];
links: any[];
};
departments: { id: number; name: string }[];
filters: {
search?: string;
status?: string;
date?: string;
department_id?: string;
};
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
error?: string;
};
}
const statusClass: Record<Attendance['status'], string> = {
hadir: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100',
izin: 'bg-blue-100 text-blue-700 hover:bg-blue-100',
sakit: 'bg-amber-100 text-amber-700 hover:bg-amber-100',
alpha: 'bg-rose-100 text-rose-700 hover:bg-rose-100',
};
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
}
function formatTime(value: string | null): string {
if (!value) return '-';
return new Date(value).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
}
export default function Index({ attendances, departments, filters }: PageProps) {
const { flash } = usePage<any>().props as SharedData;
const [search, setSearch] = useState(filters.search || '');
const [status, setStatus] = useState(filters.status || 'all');
const [departmentId, setDepartmentId] = useState(filters.department_id || 'all');
const [date, setDate] = useState(filters.date || '');
const [debouncedSearch] = useDebounce(search, 500);
useEffect(() => {
router.get(
'/admin/attendance',
{
search: debouncedSearch || '',
status: status === 'all' ? '' : status,
department_id: departmentId === 'all' ? '' : departmentId,
date,
},
{ preserveState: true, replace: true }
);
}, [debouncedSearch, status, departmentId, date]);
return (
<AppLayout>
<Head title="Manajemen Absensi" />
<div className="p-4 md:p-8 w-full space-y-4">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Manajemen Absensi</h2>
<p className="text-muted-foreground">Pantau absensi harian karyawan berdasarkan tanggal, status, dan departemen.</p>
</div>
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Absensi</CardTitle>
<Button asChild>
<Link href="/admin/attendance/create">+ Input Absensi</Link>
</Button>
</CardHeader>
<Separator />
<div className="p-4 grid grid-cols-1 md:grid-cols-4 gap-4">
<Input
placeholder="Cari nama / NIP"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Filter status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="hadir">Hadir</SelectItem>
<SelectItem value="izin">Izin</SelectItem>
<SelectItem value="sakit">Sakit</SelectItem>
<SelectItem value="alpha">Alpha</SelectItem>
</SelectContent>
</Select>
<Select value={departmentId} onValueChange={setDepartmentId}>
<SelectTrigger>
<SelectValue placeholder="Filter departemen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Departemen</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<table className="w-full text-sm text-left">
<thead className="bg-zinc-50/50 text-muted-foreground">
<tr className="border-b">
<th className="h-10 px-4 font-medium">Tanggal</th>
<th className="h-10 px-4 font-medium">Karyawan</th>
<th className="h-10 px-4 font-medium">Shift</th>
<th className="h-10 px-4 font-medium">Masuk</th>
<th className="h-10 px-4 font-medium">Pulang</th>
<th className="h-10 px-4 font-medium">Status</th>
<th className="h-10 px-4 font-medium">Catatan</th>
</tr>
</thead>
<tbody>
{attendances.data.length > 0 ? (
attendances.data.map((attendance) => (
<tr key={attendance.id} className="border-b hover:bg-zinc-50">
<td className="p-4">{formatDate(attendance.date)}</td>
<td className="p-4">
<div className="font-semibold">{attendance.employee.name}</div>
<div className="text-xs text-muted-foreground">
NIP: {attendance.employee.nip} | {attendance.employee.department?.name || '-'}
</div>
</td>
<td className="p-4">{attendance.shift}</td>
<td className="p-4">{formatTime(attendance.check_in)}</td>
<td className="p-4">{formatTime(attendance.check_out)}</td>
<td className="p-4">
<Badge className={statusClass[attendance.status]}>
{attendance.status.toUpperCase()}
</Badge>
</td>
<td className="p-4 text-muted-foreground">{attendance.notes || '-'}</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="p-8 text-center text-muted-foreground">
Belum ada data absensi.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,196 @@
import { Head, router } from '@inertiajs/react';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout';
import { Search } from 'lucide-react';
import { useDebounce } from 'use-debounce';
import * as XLSX from 'xlsx';
import { AttendanceMapModal } from '@/components/AttendanceMapModal';
interface Attendance {
id: number;
employee_name: string;
employee_nik: string;
date: string;
check_in: string | null;
check_out: string | null;
status: 'present' | 'leave' | 'dispensation';
notes: string | null;
total_hours: string | null;
latitude_in?: string | null;
longitude_in?: string | null;
latitude_out?: string | null;
longitude_out?: string | null;
}
interface PageProps {
attendances: Attendance[];
filters: { search?: string; date?: string; status?: string };
[key: string]: unknown;
}
export default function Index({ attendances, filters }: PageProps) {
const [search, setSearch] = useState(filters.search || '');
const [date, setDate] = useState(filters.date || '');
const [status, setStatus] = useState(filters.status || 'all');
const [debouncedSearch] = useDebounce(search, 500);
useEffect(() => {
if (debouncedSearch !== filters.search || date !== filters.date || (status !== 'all' && status !== filters.status)) {
router.get(
'/admin/attendances',
{ search: debouncedSearch, date, status: status === 'all' ? '' : status },
{ preserveState: true, replace: true }
);
}
}, [debouncedSearch, date, status]);
const exportExcel = () => {
const rows = attendances.map((a, i) => ({
No: i + 1,
Nama: a.employee_name,
NIK: a.employee_nik,
Tanggal: a.date,
'Jam Masuk': a.check_in || '-',
'Jam Keluar': a.check_out || '-',
Status: a.status === 'present' ? 'Hadir' : a.status === 'leave' ? 'Izin/Cuti' : 'Dispensasi',
'Total Jam': a.total_hours ? `${a.total_hours} Jam` : '-',
Catatan: a.notes || '-',
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Absensi');
XLSX.writeFile(wb, `Data_Absensi_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
const formatStatus = (status: string) => {
switch (status) {
case 'present': return <span className="text-green-600 font-medium">Hadir</span>;
case 'leave': return <span className="text-yellow-600 font-medium">Izin/Cuti</span>;
case 'dispensation': return <span className="text-blue-600 font-medium">Dispensasi</span>;
default: return <span>{status}</span>;
}
};
return (
<AppLayout>
<Head title="Rekap Absensi" />
<div className="p-4 md:p-8 w-full space-y-4">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Rekap Absensi Karyawan</h2>
<p className="text-muted-foreground">Monitoring kehadiran, izin, dan dispensasi karyawan.</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Absensi</CardTitle>
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
</CardHeader>
<Separator />
<div className="p-4 flex flex-col md:flex-row gap-4 items-end">
<div className="w-full md:w-1/3 space-y-1">
<label className="text-xs text-muted-foreground font-medium">Cari Karyawan</label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Nama / NIK..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="w-full md:w-1/4 space-y-1">
<label className="text-xs text-muted-foreground font-medium">Tanggal</label>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="w-full md:w-1/4 space-y-1">
<label className="text-xs text-muted-foreground font-medium">Status</label>
<Select value={status} onValueChange={(val) => setStatus(val)}>
<SelectTrigger><SelectValue placeholder="Semua Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="present">Hadir</SelectItem>
<SelectItem value="leave">Izin / Cuti</SelectItem>
<SelectItem value="dispensation">Dispensasi</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:ml-auto">
<Button variant="ghost" onClick={() => { setSearch(''); setDate(''); setStatus('all'); }}>Reset</Button>
</div>
</div>
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nama</TableHead>
<TableHead>NIK</TableHead>
<TableHead>Tanggal</TableHead>
<TableHead>Jam Masuk</TableHead>
<TableHead>Jam Keluar</TableHead>
<TableHead>Lokasi Peta</TableHead>
<TableHead>Keterangan</TableHead>
<TableHead className="text-right">Total Jam Kerja</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attendances.length > 0 ? (
attendances.map((attendance) => (
<TableRow key={attendance.id}>
<TableCell className="font-medium text-gray-900">{attendance.employee_name}</TableCell>
<TableCell className="text-gray-600">{attendance.employee_nik}</TableCell>
<TableCell className="text-gray-600">{attendance.date}</TableCell>
<TableCell>{attendance.check_in || '-'}</TableCell>
<TableCell>{attendance.check_out || '-'}</TableCell>
<TableCell>
<AttendanceMapModal
latitudeIn={attendance.latitude_in}
longitudeIn={attendance.longitude_in}
latitudeOut={attendance.latitude_out}
longitudeOut={attendance.longitude_out}
employeeName={attendance.employee_name}
date={attendance.date}
/>
</TableCell>
<TableCell>
<div className="flex flex-col">
{formatStatus(attendance.status)}
{attendance.notes && (
<span className="text-xs text-gray-500 mt-1">{attendance.notes}</span>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">
{attendance.total_hours ? `${attendance.total_hours} Jam` : '-'}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
Belum ada data absensi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -68,7 +68,7 @@ export default function Create() {
<Link href="/admin/departments">Batal</Link>
</Button>
<Button type="submit" disabled={processing}>
Simpan Departemen
{processing ? 'Menyimpan...' : 'Simpan Departemen'}
</Button>
</div>

View File

@ -75,7 +75,7 @@ export default function Edit({ department }: PageProps) {
<Link href="/admin/departments">Batal</Link>
</Button>
<Button type="submit" disabled={processing}>
Simpan Perubahan
{processing ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
</div>

View File

@ -9,8 +9,14 @@ import {
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"; // Import Tooltip
} from "@/components/ui/tooltip";
import { Input } from '@/components/ui/input';
import AppLayout from '@/layouts/app-layout';
import { Search } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useDebounce } from 'use-debounce';
import { router } from '@inertiajs/react';
import * as XLSX from 'xlsx';
interface Department {
id: number;
@ -21,51 +27,73 @@ interface Department {
interface PageProps {
departments: Department[];
filters: { search?: string };
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
error?: string;
};
}
export default function Index({ departments, filters }: PageProps) {
const [search, setSearch] = useState(filters.search || '');
const [debouncedSearch] = useDebounce(search, 500);
export default function Index({ departments }: PageProps) {
const { flash } = usePage<any>().props as SharedData;
useEffect(() => {
if (debouncedSearch !== filters.search) {
router.get(
'/admin/departments',
{ search: debouncedSearch },
{ preserveState: true, replace: true }
);
}
}, [debouncedSearch]);
const exportExcel = () => {
const rows = departments.map((d, i) => ({
No: i + 1,
'Nama Departemen': d.name,
Deskripsi: d.description || '-',
'Total Karyawan': d.employees_count,
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Departemen');
XLSX.writeFile(wb, `Data_Departemen_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
return (
<AppLayout>
<Head title="Manajemen Departemen" />
<div className="p-4 md:p-8 w-full space-y-4">
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
<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>
{/* 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>
<Card>
<CardHeader className="pb-4">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Departemen</CardTitle>
<div className="flex gap-2">
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
<Button asChild>
<Link href="/admin/departments/create">+ Tambah Departemen</Link>
</Button>
</div>
</CardHeader>
<Separator />
<div className="p-4 w-full md:w-1/3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Cari departemen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<table className="w-full text-sm text-left">

View File

@ -76,7 +76,8 @@ export default function Create({ departments, positions }: PageProps) {
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
type="email"
placeholder="contoh@email.com"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>
@ -89,6 +90,7 @@ export default function Create({ departments, positions }: PageProps) {
value={data.phone_number}
onChange={e => setData('phone_number', e.target.value)}
/>
{errors.phone_number && <div className="text-red-500 text-xs">{errors.phone_number}</div>}
</div>
</div>
@ -101,6 +103,7 @@ export default function Create({ departments, positions }: PageProps) {
value={data.place_of_birth}
onChange={e => setData('place_of_birth', e.target.value)}
/>
{errors.place_of_birth && <div className="text-red-500 text-xs">{errors.place_of_birth}</div>}
</div>
<div className="space-y-2">
<Label>Tanggal Lahir</Label>
@ -131,6 +134,7 @@ export default function Create({ departments, positions }: PageProps) {
value={data.address}
onChange={e => setData('address', e.target.value)}
/>
{errors.address && <div className="text-red-500 text-xs">{errors.address}</div>}
</div>
</div>
</div>
@ -223,7 +227,7 @@ export default function Create({ departments, positions }: PageProps) {
<Link href="/admin/employees">Batal</Link>
</Button>
<Button type="submit" disabled={processing} className="px-8">
Simpan Data
{processing ? 'Menyimpan...' : 'Simpan Data'}
</Button>
</div>

View File

@ -8,9 +8,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
// Definisi Tipe Data Employee yang diterima dari Controller
interface Employee {
id: number;
name: string;
nip: string;
gender: string;
birth_date: string;
@ -24,7 +25,7 @@ interface Employee {
user: {
name: string;
email: string;
};
} | null;
}
interface PageProps {
@ -35,10 +36,10 @@ interface PageProps {
}
export default function Edit({ employee, departments, positions }: PageProps) {
// Inisialisasi Form dengan Data Lama (Pre-filled)
// Inisialisasi State
const { data, setData, put, processing, errors } = useForm({
name: employee.user.name,
email: employee.user.email,
name: employee.name,
email: employee.user?.email ?? '',
nip: employee.nip,
gender: employee.gender,
birth_date: employee.birth_date,
@ -58,7 +59,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
return (
<AppLayout>
<Head title={`Edit Karyawan: ${employee.user.name}`} />
<Head title={`Edit Karyawan: ${employee.name}`} />
<div className="p-4 md:p-8 max-w-5xl mx-auto">
<Button variant="outline" asChild className="mb-6">
@ -109,6 +110,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
value={data.phone_number}
onChange={e => setData('phone_number', e.target.value)}
/>
{errors.phone_number && <div className="text-red-500 text-xs">{errors.phone_number}</div>}
</div>
</div>
@ -121,6 +123,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
value={data.place_of_birth}
onChange={e => setData('place_of_birth', e.target.value)}
/>
{errors.place_of_birth && <div className="text-red-500 text-xs">{errors.place_of_birth}</div>}
</div>
<div className="space-y-2">
<Label>Tanggal Lahir</Label>
@ -151,6 +154,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
value={data.address}
onChange={e => setData('address', e.target.value)}
/>
{errors.address && <div className="text-red-500 text-xs">{errors.address}</div>}
</div>
</div>
</div>
@ -187,6 +191,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
))}
</SelectContent>
</Select>
{errors.department_id && <div className="text-red-500 text-xs">{errors.department_id}</div>}
</div>
</div>
@ -202,6 +207,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
))}
</SelectContent>
</Select>
{errors.position_id && <div className="text-red-500 text-xs">{errors.position_id}</div>}
</div>
<div className="grid grid-cols-2 gap-4">
@ -215,6 +221,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
<SelectItem value="Magang">Magang</SelectItem>
</SelectContent>
</Select>
{errors.status && <div className="text-red-500 text-xs">{errors.status}</div>}
</div>
<div className="space-y-2">
@ -224,6 +231,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
value={data.join_date}
onChange={e => setData('join_date', e.target.value)}
/>
{errors.join_date && <div className="text-red-500 text-xs">{errors.join_date}</div>}
</div>
</div>
</div>
@ -238,7 +246,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
<Link href="/admin/employees">Batal</Link>
</Button>
<Button type="submit" disabled={processing} className="px-8">
Perbarui Data
{processing ? 'Menyimpan...' : 'Perbarui Data'}
</Button>
</div>

View File

@ -18,6 +18,8 @@ import AppLayout from '@/layouts/app-layout';
interface Employee {
id: number;
name: string;
email: string | null;
nip: string;
status: string;
join_date: string;
@ -36,10 +38,7 @@ interface Employee {
}
interface PageProps {
employees: {
data: Employee[];
links: any[];
};
employees: Employee[];
departments: { id: number; name: string }[];
filters: {
search?: string;
@ -48,15 +47,9 @@ interface PageProps {
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
error?: string;
};
}
import * as XLSX from 'xlsx';
export default function Index({ employees, departments, filters }: PageProps) {
const { flash } = usePage<any>().props as SharedData;
const [search, setSearch] = useState(filters.search || '');
const [departmentId, setDepartmentId] = useState(filters.department_id || 'all');
const [debouncedSearch] = useDebounce(search, 500);
@ -74,22 +67,43 @@ export default function Index({ employees, departments, filters }: PageProps) {
}
}, [debouncedSearch, departmentId]);
const exportExcel = () => {
const rows = employees.map((p, i) => ({
No: i + 1,
Nama: p.name || '-',
Email: p.email || '-',
NIP: p.nip,
Departemen: p.department?.name || '-',
Jabatan: p.position?.name || '-',
Status: p.status,
Bergabung: new Date(p.join_date).toLocaleDateString('id-ID'),
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Data Karyawan');
XLSX.writeFile(wb, `Data_Karyawan_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
return (
<AppLayout>
<Head title="Manajemen Karyawan" />
<div className="p-4 md:p-8 space-y-4">
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
<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>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle className="text-xl font-bold">Daftar Karyawan</CardTitle>
<Button asChild>
<Link href="/admin/employees/create">+ Tambah Karyawan</Link>
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
<Button asChild>
<Link href="/admin/employees/create">+ Tambah Karyawan</Link>
</Button>
</div>
</CardHeader>
<Separator />
@ -134,12 +148,12 @@ export default function Index({ employees, departments, filters }: PageProps) {
</tr>
</thead>
<tbody>
{employees.data.length > 0 ? (
employees.data.map((employee) => (
{employees.length > 0 ? (
employees.map((employee) => (
<tr key={employee.id} className="border-b hover:bg-zinc-50">
<td className="p-4">
<div className="font-bold">{employee.user?.name}</div>
<div className="text-xs text-muted-foreground">{employee.user?.email}</div>
<div className="font-bold">{employee.name}</div>
<div className="text-xs text-muted-foreground">{employee.email || '-'}</div>
<div className="text-xs text-zinc-400 mt-1">NIP: {employee.nip}</div>
</td>
<td className="p-4">

View File

@ -0,0 +1,387 @@
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';
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[];
}
function formatRupiah(value: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(value);
}
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
const netSalary = data.details.reduce((acc, item) => {
return item.type === 'bonus'
? acc + (item.amount || 0)
: acc - (item.amount || 0);
}, data.basic_salary);
// Set basic_salary otomatis
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
const addDetail = () => {
setData('details', [
...data.details,
{ name: '', type: 'bonus', amount: 0 },
]);
};
// Update baris
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
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="space-y-1">
<div
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>
{/* Error per baris detail */}
<div className="px-1">
{errors[`details.${index}.name`] && (
<p className="text-xs text-red-500">{errors[`details.${index}.name`]}</p>
)}
{errors[`details.${index}.type`] && (
<p className="text-xs text-red-500">{errors[`details.${index}.type`]}</p>
)}
{errors[`details.${index}.amount`] && (
<p className="text-xs text-red-500">{errors[`details.${index}.amount`]}</p>
)}
</div>
</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,273 @@
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 { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
import { PlusCircle, Trash2 } from 'lucide-react';
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';
employee: {
id: number;
name: string;
nip: string;
position: string;
department: string;
};
}
interface PageProps {
payroll: PayrollData;
}
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('-');
return new Date(Number(year), Number(month) - 1, 1)
.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
}
export default function Edit({ payroll }: PageProps) {
const { data, setData, put, processing, errors } = useForm<{
basic_salary: number;
details: DetailItem[];
}>({
basic_salary: payroll.basic_salary,
details: payroll.details,
});
// Hitung net salary
const netSalary = data.details.reduce((acc, item) => {
return item.type === 'bonus'
? acc + (item.amount || 0)
: acc - (item.amount || 0);
}, data.basic_salary);
const addDetail = () => {
setData('details', [...data.details, { name: '', type: 'bonus', amount: 0 }]);
};
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);
};
const removeDetail = (index: number) => {
setData('details', data.details.filter((_, i) => i !== index));
};
const submit = (e: React.FormEvent) => {
e.preventDefault();
put(`/admin/payrolls/${payroll.id}`);
};
return (
<AppLayout>
<Head title={`Edit Payroll — ${payroll.employee.name}`} />
<div className="mx-auto max-w-4xl p-4 md:p-8">
<Button variant="outline" asChild className="mb-6">
<Link href={`/admin/payrolls/${payroll.id}`}> Kembali ke Slip</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>Edit Payroll</CardTitle>
<p className="text-sm text-muted-foreground">
Revisi komponen gaji untuk periode ini. Karyawan dan periode tidak dapat diubah.
</p>
</CardHeader>
<Separator />
<CardContent className="pt-6">
{/* Info Payroll (read-only) */}
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg border bg-muted/40 p-4 text-sm sm:grid-cols-4">
<div>
<p className="text-xs text-muted-foreground">Karyawan</p>
<p className="font-semibold">{payroll.employee.name}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">NIP</p>
<p className="font-semibold">{payroll.employee.nip}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Jabatan</p>
<p className="font-semibold">{payroll.employee.position ?? '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Periode</p>
<p className="font-semibold">{formatPeriod(payroll.period)}</p>
</div>
</div>
<form onSubmit={submit} className="space-y-8">
{/* ── Gaji Pokok ── */}
<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>
Gaji Pokok
</h3>
<div className="max-w-xs space-y-2">
<Label>Gaji Pokok (Rp)</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 />
{/* ── 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">2</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. Klik &quot;Tambah Komponen&quot; untuk menambah bonus atau potongan.
</p>
) : (
<div className="space-y-3">
<div className="grid grid-cols-[1fr_140px_160px_40px] gap-3 px-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Nama Komponen</span>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Tipe</span>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Nominal (Rp)</span>
<span />
</div>
{data.details.map((item, index) => (
<div key={index} className="space-y-1">
<div className="grid grid-cols-[1fr_140px_160px_40px] items-center gap-3">
<Input
placeholder="cth: Bonus Kinerja"
value={item.name}
onChange={(e) => updateDetail(index, 'name', e.target.value)}
/>
<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>
<Input
type="number"
min={0}
placeholder="0"
value={item.amount || ''}
onChange={(e) => updateDetail(index, 'amount', e.target.value)}
/>
<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 className="px-1">
{errors[`details.${index}.name` as keyof typeof errors] && (
<p className="text-xs text-red-500">{errors[`details.${index}.name` as keyof typeof errors]}</p>
)}
</div>
</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/${payroll.id}`}>Batal</Link>
</Button>
<Button type="submit" disabled={processing} className="px-8">
{processing ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,254 @@
import { Head, Link, router, usePage } 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 { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout';
import { PlusCircle, FileText, Search, CheckCircle2, Clock, Pencil, Download } from 'lucide-react';
import { useState } from 'react';
import * as XLSX from 'xlsx';
interface Payroll {
id: number;
employee_name: string;
employee_nip: string;
period: string;
net_salary: number;
status: 'pending' | 'paid';
}
interface PageProps {
payrolls: Payroll[];
filters: { search?: string; period?: string; status?: string };
[key: string]: unknown;
}
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('-');
return new Date(Number(year), Number(month) - 1, 1)
.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
}
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
return status === 'paid' ? (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 gap-1">
<CheckCircle2 className="h-3 w-3" />
Telah Dikirim
</Badge>
) : (
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 gap-1">
<Clock className="h-3 w-3" />
Menunggu
</Badge>
);
}
export default function Index({ payrolls, filters }: PageProps) {
const [search, setSearch] = useState(filters.search ?? '');
const [period, setPeriod] = useState(filters.period ?? '');
const [status, setStatus] = useState(filters.status ?? '');
const [processingId, setProcessingId] = useState<number | null>(null);
const applyFilter = () => {
router.get('/admin/payrolls', { search, period, status }, { preserveScroll: true });
};
const resetFilter = () => {
setSearch(''); setPeriod(''); setStatus('');
router.get('/admin/payrolls', {}, { preserveScroll: true });
};
const toggleStatus = (payroll: Payroll) => {
const newStatus = payroll.status === 'paid' ? 'pending' : 'paid';
setProcessingId(payroll.id);
router.patch(
`/admin/payrolls/${payroll.id}/status`,
{ status: newStatus },
{ preserveScroll: true, onFinish: () => setProcessingId(null) },
);
};
const exportExcel = () => {
const rows = payrolls.map((p, i) => ({
No: i + 1,
Karyawan: p.employee_name,
NIP: p.employee_nip,
Periode: formatPeriod(p.period),
'Gaji Bersih': p.net_salary,
Status: p.status === 'paid' ? 'Telah Dikirim' : 'Menunggu',
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Payroll');
XLSX.writeFile(wb, `payroll_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
return (
<AppLayout>
<Head title="Daftar Payroll" />
<div className="p-4 md:p-8 w-full space-y-4">
<div className="flex items-center justify-between">
<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>
<div className="flex gap-2">
<Button variant="outline" onClick={exportExcel} className="gap-2">
<Download className="h-4 w-4" />
Export Excel
</Button>
<Button asChild>
<Link href="/admin/payrolls/create" className="gap-2">
<PlusCircle className="h-4 w-4" />
Generate Payroll
</Link>
</Button>
</div>
</div>
{/* Filter */}
<Card>
<CardContent className="pt-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="space-y-1 flex-1 min-w-[180px]">
<label className="text-xs font-medium text-muted-foreground">Cari Karyawan</label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Nama / NIP..."
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && applyFilter()}
/>
</div>
</div>
<div className="space-y-1 w-[160px]">
<label className="text-xs font-medium text-muted-foreground">Periode</label>
<Input
type="month"
value={period}
onChange={e => setPeriod(e.target.value)}
/>
</div>
<div className="space-y-1 w-[160px]">
<label className="text-xs font-medium text-muted-foreground">Status</label>
<Select value={status || 'all'} onValueChange={v => setStatus(v === 'all' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Semua Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="pending">Menunggu</SelectItem>
<SelectItem value="paid">Telah Dikirim</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={applyFilter}>Filter</Button>
<Button variant="ghost" onClick={resetFilter}>Reset</Button>
</div>
</CardContent>
</Card>
{/* Tabel */}
<Card>
<CardHeader className="pb-4">
<CardTitle>Daftar Slip Gaji</CardTitle>
</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">
<button
onClick={() => toggleStatus(payroll)}
disabled={processingId === payroll.id}
className="cursor-pointer disabled:opacity-50"
title="Klik untuk ubah status"
>
<StatusBadge status={payroll.status} />
</button>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button asChild variant="ghost" size="sm">
<Link href={`/admin/payrolls/${payroll.id}/edit`}>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href={`/admin/payrolls/${payroll.id}`}>
Lihat Slip
</Link>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,330 @@
import { Head, Link, router } from '@inertiajs/react';
import { useState } from '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, Pencil, CheckCircle2, Clock } from 'lucide-react';
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;
}
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' });
}
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 gap-1">
<CheckCircle2 className="h-3 w-3" />
Telah Dikirim
</Badge>
) : (
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200 gap-1">
<Clock className="h-3 w-3" />
Menunggu
</Badge>
);
}
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);
const [toggling, setToggling] = useState(false);
const toggleStatus = () => {
const newStatus = payroll.status === 'paid' ? 'pending' : 'paid';
setToggling(true);
router.patch(
`/admin/payrolls/${payroll.id}/status`,
{ status: newStatus },
{ onFinish: () => setToggling(false) },
);
};
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 gap-2 print:hidden">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/payrolls"> Kembali</Link>
</Button>
<div className="flex items-center gap-2">
{payroll.status !== 'paid' && (
<>
<Button
variant="outline"
size="sm"
onClick={toggleStatus}
disabled={toggling}
className="gap-1.5"
>
<CheckCircle2 className="h-3.5 w-3.5" /> Tandai Terkirim
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
asChild
>
<Link href={`/admin/payrolls/${payroll.id}/edit`}>
<Pencil className="h-3.5 w-3.5" />
Edit
</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>
</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,20 +40,45 @@ 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
{processing ? 'Menyimpan...' : 'Simpan Jabatan'}
</Button>
<Button type="button" variant="ghost" asChild>
<Link href="/admin/positions">Batal</Link>

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,19 +50,42 @@ 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
{processing ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Button type="button" variant="ghost" asChild>
<Link href="/admin/positions">Batal</Link>

View File

@ -5,7 +5,13 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Input } from '@/components/ui/input';
import AppLayout from '@/layouts/app-layout';
import { Search } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useDebounce } from 'use-debounce';
import { router } from '@inertiajs/react';
import * as XLSX from 'xlsx';
interface Position {
id: number;
@ -15,51 +21,72 @@ interface Position {
interface PageProps {
positions: Position[];
filters: { search?: string };
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
error?: string;
};
}
export default function Index({ positions, filters }: PageProps) {
const [search, setSearch] = useState(filters.search || '');
const [debouncedSearch] = useDebounce(search, 500);
export default function Index({ positions }: PageProps) {
const { flash } = usePage<any>().props as SharedData;
useEffect(() => {
if (debouncedSearch !== filters.search) {
router.get(
'/admin/positions',
{ search: debouncedSearch },
{ preserveState: true, replace: true }
);
}
}, [debouncedSearch]);
const exportExcel = () => {
const rows = positions.map((p, i) => ({
No: i + 1,
'Nama Jabatan': p.name,
'Total Karyawan': p.employees_count,
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Jabatan');
XLSX.writeFile(wb, `Data_Jabatan_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
return (
<AppLayout>
<Head title="Manajemen Jabatan" />
<div className="p-4 md:p-8 w-full space-y-4">
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
<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>
{/* 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>
<Card>
<CardHeader className="pb-4">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Jabatan</CardTitle>
<div className="flex gap-2">
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
<Button asChild>
<Link href="/admin/positions/create">+ Tambah Jabatan</Link>
</Button>
</div>
</CardHeader>
<Separator />
<div className="p-4 w-full md:w-1/3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Cari jabatan..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<table className="w-full text-sm text-left">

View File

@ -1,42 +1,59 @@
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' },
];
export default function Create() {
interface Employee {
id: number;
name: string;
nip: string;
position: string;
}
interface PageProps {
employees: Employee[];
}
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="p-4 md:p-8 w-full mx-auto">
<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>
@ -45,114 +62,104 @@ export default function Create() {
<Card>
<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>
</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>
{/* Helper text password default */}
<p className="text-xs text-muted-foreground border border-dashed rounded-md px-3 py-2">
*password default user adalah <span className="font-semibold text-foreground">'password'</span>
</p>
<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

@ -4,9 +4,14 @@ import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem } from '@/types';
import { Search } from 'lucide-react';
import { useEffect } from 'react';
import { useDebounce } from 'use-debounce';
import * as XLSX from 'xlsx';
interface User {
id: number;
@ -18,6 +23,7 @@ interface User {
interface PageProps {
users: User[];
filters: { search?: string; role?: string };
[key: string]: unknown;
}
@ -25,10 +31,35 @@ const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Manajemen Pengguna', href: '/admin/users' },
];
export default function Index({ users }: PageProps) {
const { flash } = usePage<any>().props;
export default function Index({ users, filters }: PageProps) {
const [search, setSearch] = useState(filters.search || '');
const [roleFilter, setRoleFilter] = useState(filters.role || 'all');
const [debouncedSearch] = useDebounce(search, 500);
const [processingId, setProcessingId] = useState<number | null>(null);
useEffect(() => {
if (debouncedSearch !== filters.search || (roleFilter !== 'all' && roleFilter !== filters.role)) {
router.get(
'/admin/users',
{ search: debouncedSearch, role: roleFilter === 'all' ? '' : roleFilter },
{ preserveState: true, replace: true }
);
}
}, [debouncedSearch, roleFilter]);
const exportExcel = () => {
const rows = users.map((u, i) => ({
No: i + 1,
Nama: u.name,
Email: u.email,
Role: u.role.toUpperCase(),
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Pengguna');
XLSX.writeFile(wb, `Data_Pengguna_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
const updateRole = (id: number, role: string) => {
setProcessingId(id);
router.patch(
@ -47,35 +78,48 @@ export default function Index({ users }: PageProps) {
<div className="p-4 md:p-8 w-full space-y-4">
{/* Flash Messages */}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
{/* 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>
{/* 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>
{/* Tabel */}
<Card>
<CardHeader className="pb-4">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Pengguna</CardTitle>
<div className="flex gap-2">
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
<Button asChild>
<Link href="/admin/users/create">+ Tambah User Baru</Link>
</Button>
</div>
</CardHeader>
<Separator />
<div className="p-4 flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Cari pengguna..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="w-full md:w-1/4">
<Select value={roleFilter} onValueChange={(val) => setRoleFilter(val)}>
<SelectTrigger><SelectValue placeholder="Filter Role" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Role</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="employee">Employee</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<table className="w-full text-sm text-left">

View File

@ -1,120 +1,141 @@
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-16 w-16 items-center justify-center">
<img src={"/assets/logo.jpg"} alt="Logo" className="h-full w-full object-contain p-1" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">HRIS APP</h1>
<p className="mt-1 text-sm text-muted-foreground">PT. ZHANHUI JAYA INDONESIA</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

@ -1,7 +1,7 @@
import { Head, Link } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, Building2, Briefcase, UserPlus } from 'lucide-react';
import { Users, Building2, Briefcase, UserPlus, Banknote } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
PieChart, Pie, Cell, Legend
@ -12,13 +12,35 @@ interface DashboardProps {
total_employees: number;
total_departments: number;
total_positions: number;
payroll_realisasi: number;
};
genderData: any[];
deptData: any[];
latestEmployees: any[];
genderData: { name: string; value: number; fill: string }[];
deptData: { name: string; employees: number }[];
latestEmployees: {
id: number;
join_date: string;
user: { name: string };
position: { name: string };
department: { name: string };
}[];
currentPeriod: string;
}
export default function Dashboard({ stats, genderData, deptData, latestEmployees }: DashboardProps) {
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('-');
return new Date(Number(year), Number(month) - 1, 1)
.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
}
export default function Dashboard({ stats, genderData, deptData, latestEmployees, currentPeriod }: DashboardProps) {
return (
<AppLayout breadcrumbs={[{ title: 'Dashboard', href: '/dashboard' }]}>
<Head title="Dashboard" />
@ -26,7 +48,7 @@ export default function Dashboard({ stats, genderData, deptData, latestEmployees
<div className="flex flex-1 flex-col gap-4 p-4 md:p-8 pt-0">
{/* CARD STATISTIK */}
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Karyawan</CardTitle>
@ -57,6 +79,23 @@ export default function Dashboard({ stats, genderData, deptData, latestEmployees
<p className="text-xs text-muted-foreground">Posisi pekerjaan</p>
</CardContent>
</Card>
{/* Card Realisasi Gaji */}
<Card className="border-emerald-200 bg-emerald-50/50 dark:bg-emerald-950/20 dark:border-emerald-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-emerald-800 dark:text-emerald-300">
Realisasi Gaji Bulan Ini
</CardTitle>
<Banknote className="h-4 w-4 text-emerald-600" />
</CardHeader>
<CardContent>
<div className="text-xl font-bold text-emerald-700 dark:text-emerald-400 leading-tight">
{formatRupiah(stats.payroll_realisasi)}
</div>
<p className="text-xs text-emerald-600/80 dark:text-emerald-500 mt-1">
Status &quot;Telah Dikirim&quot; · {formatPeriod(currentPeriod)}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
@ -137,14 +176,16 @@ export default function Dashboard({ stats, genderData, deptData, latestEmployees
</CardHeader>
<CardContent>
<div className="space-y-8">
{latestEmployees.map((emp) => (
{latestEmployees.length === 0 ? (
<p className="text-sm text-center text-muted-foreground py-4">Belum ada data karyawan.</p>
) : latestEmployees.map((emp) => (
<div key={emp.id} className="flex items-center">
<div className="h-9 w-9 rounded-full bg-slate-100 flex items-center justify-center border">
<UserPlus className="h-5 w-5 text-slate-500" />
</div>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">{emp.user.name}</p>
<p className="text-xs text-muted-foreground">{emp.position.name} {emp.department.name}</p>
<p className="text-sm font-medium leading-none">{emp.user?.name ?? '-'}</p>
<p className="text-xs text-muted-foreground">{emp.position?.name ?? '-'} {emp.department?.name ?? '-'}</p>
</div>
<div className="ml-auto font-medium text-xs text-muted-foreground">
{new Date(emp.join_date).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })}

View File

@ -0,0 +1,376 @@
import { Head, useForm, usePage } from '@inertiajs/react';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import EmployeeLayout from '@/layouts/employee-layout';
import { AttendanceMapModal } from '@/components/AttendanceMapModal';
import { MapPin, AlertCircle, RefreshCw, CheckCircle2, LogOut } from 'lucide-react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
interface Attendance {
id: number;
date: string;
check_in: string | null;
check_out: string | null;
status: 'present' | 'leave' | 'dispensation';
notes: string | null;
latitude_in?: string | null;
longitude_in?: string | null;
latitude_out?: string | null;
longitude_out?: string | null;
}
interface PageProps {
attendances: Attendance[];
todayAttendance: Attendance | null;
[key: string]: unknown;
}
export default function AttendanceIndex({ attendances, todayAttendance }: PageProps) {
const { flash } = usePage<any>().props;
const [locationError, setLocationError] = useState<string | null>(null);
const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
const [isLoadingLocation, setIsLoadingLocation] = useState<boolean>(true);
const { data: regulerData, setData: setRegulerData, post: postReguler, processing: processingReguler } = useForm({
latitude_in: '',
longitude_in: '',
latitude_out: '',
longitude_out: '',
});
const { data: leaveData, setData: setLeaveData, post: postLeave, processing: processingLeave, reset: resetLeave, errors: leaveErrors } = useForm({
date: '',
notes: '',
});
const { data: dispenData, setData: setDispenData, post: postDispen, processing: processingDispen, reset: resetDispen, errors: dispenErrors } = useForm({
latitude_in: '',
longitude_in: '',
notes: '',
});
const getLocation = () => {
setIsLoadingLocation(true);
setLocationError(null);
if (!navigator.geolocation) {
setLocationError('Geolocation tidak didukung oleh browser ini.');
setIsLoadingLocation(false);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
setCoordinates({ lat, lng });
setRegulerData({
...regulerData,
latitude_in: lat.toString(),
longitude_in: lng.toString(),
latitude_out: lat.toString(),
longitude_out: lng.toString(),
});
setDispenData({
...dispenData,
latitude_in: lat.toString(),
longitude_in: lng.toString(),
});
setIsLoadingLocation(false);
},
(error) => {
let errorMsg = 'Gagal mengambil lokasi.';
if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi di pengaturan browser/perangkat Anda.';
setLocationError(errorMsg);
setIsLoadingLocation(false);
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
);
};
useEffect(() => {
getLocation();
}, []);
const handleClockIn = (e: React.FormEvent) => {
e.preventDefault();
postReguler('/employee/attendances/clock-in');
};
const handleClockOut = (e: React.FormEvent) => {
e.preventDefault();
postReguler('/employee/attendances/clock-out');
};
const handleLeaveSubmit = (e: React.FormEvent) => {
e.preventDefault();
postLeave('/employee/attendances/leave', {
onSuccess: () => resetLeave()
});
};
const handleDispenSubmit = (e: React.FormEvent) => {
e.preventDefault();
postDispen('/employee/attendances/dispensation', {
onSuccess: () => resetDispen()
});
};
const formatStatus = (status: string) => {
switch (status) {
case 'present': return 'Hadir';
case 'leave': return 'Izin/Cuti';
case 'dispensation': return 'Dispensasi';
default: return status;
}
};
const isClockInDisabled = processingReguler || isLoadingLocation || !coordinates || Boolean(todayAttendance?.check_in);
const isClockOutDisabled = processingReguler || isLoadingLocation || !coordinates || !todayAttendance || Boolean(todayAttendance?.check_out);
return (
<EmployeeLayout title="Absensi Karyawan">
<div className="bg-gradient-to-r from-sky-400 to-blue-500 text-white md:rounded-b-3xl shadow-sm relative mb-8 md:mb-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-20 md:pb-24">
<div className="flex flex-col mb-2">
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">Portal Absensi</h1>
<p className="text-sky-100 text-sm md:text-base opacity-90 mt-2">Lakukan absen masuk, pulang, atau ajukan izin.</p>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 md:-mt-24 relative z-10 pb-12 space-y-6 md:space-y-8">
<div className={`p-4 md:p-5 rounded-xl md:rounded-2xl text-sm shadow-sm ${
isLoadingLocation ? 'bg-sky-100 text-sky-800' :
locationError ? 'bg-red-100 text-red-800' :
'bg-green-100 text-green-800'
}`}>
<div className="flex items-start md:items-center gap-3">
{isLoadingLocation ? (
<RefreshCw className="w-5 h-5 md:w-6 md:h-6 animate-spin shrink-0 mt-0.5 md:mt-0" />
) : locationError ? (
<AlertCircle className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
) : (
<MapPin className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
)}
<div className="flex-1 md:flex md:items-center md:justify-between">
<div>
<p className="font-semibold mb-0.5 md:text-base">
{isLoadingLocation ? 'Mencari lokasi Anda...' :
locationError ? 'Gagal Akses Lokasi' :
'Lokasi Ditemukan'}
</p>
<p className="text-xs md:text-sm opacity-90">
{isLoadingLocation ? 'Harap tunggu, pastikan GPS aktif.' :
locationError ? locationError :
`Koordinat: ${coordinates?.lat.toFixed(6)}, ${coordinates?.lng.toFixed(6)}`}
</p>
</div>
{locationError && (
<Button onClick={getLocation} variant="outline" size="sm" className="mt-2 md:mt-0 h-8 md:h-9 bg-white">
Coba Lagi
</Button>
)}
</div>
</div>
{/* Peta Preview - Hanya muncul jika koordinat berhasil didapatkan */}
{coordinates && !isLoadingLocation && !locationError && (
<div className="mt-4 h-48 md:h-64 w-full rounded-md overflow-hidden border-2 border-green-200 shadow-sm">
<MapContainer
center={[coordinates.lat, coordinates.lng]}
zoom={16}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[coordinates.lat, coordinates.lng]}>
<Popup>
<div className="text-center">
<p className="font-semibold text-sm">📍 Lokasi Anda Saat Ini</p>
<p className="text-xs text-slate-600 mt-1">
Lat: {coordinates.lat.toFixed(6)}<br />
Lng: {coordinates.lng.toFixed(6)}
</p>
</div>
</Popup>
</Marker>
</MapContainer>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8">
<div className="lg:col-span-1">
<Card className="border-slate-100 shadow-md h-full">
<CardContent className="p-4 sm:p-6">
<Tabs defaultValue="reguler" className="w-full">
<TabsList className="w-full grid grid-cols-3 mb-6 bg-slate-100/50">
<TabsTrigger value="reguler">Harian</TabsTrigger>
<TabsTrigger value="izin">Izin</TabsTrigger>
<TabsTrigger value="dispen">Dispen</TabsTrigger>
</TabsList>
<TabsContent value="reguler" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
<div className="text-center mb-6">
<h3 className="text-sm md:text-base font-semibold text-slate-800">Absen Harian</h3>
<p className="text-xs md:text-sm text-slate-500 mt-1">
{todayAttendance
? 'Anda sudah memiliki catatan absensi hari ini.'
: 'Sistem membutuhkan akses lokasi.'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
onClick={handleClockIn}
disabled={isClockInDisabled}
className="w-full bg-sky-600 hover:bg-sky-700 h-14 md:h-16 flex flex-col gap-1 items-center justify-center rounded-xl transition-all"
>
<CheckCircle2 className="w-5 h-5 md:w-6 md:h-6" />
<span className="text-xs md:text-sm font-semibold">Clock In</span>
</Button>
<Button
onClick={handleClockOut}
disabled={isClockOutDisabled}
variant="outline"
className="w-full h-14 md:h-16 flex flex-col gap-1 items-center justify-center rounded-xl border-slate-200 transition-all"
>
<LogOut className="w-5 h-5 md:w-6 md:h-6 text-slate-500" />
<span className="text-xs md:text-sm font-semibold text-slate-700">Clock Out</span>
</Button>
</div>
</TabsContent>
<TabsContent value="izin" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
<form onSubmit={handleLeaveSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="date" className="text-xs md:text-sm">Tanggal Izin</Label>
<Input
id="date"
type="date"
required
value={leaveData.date}
onChange={e => setLeaveData('date', e.target.value)}
className="h-11"
/>
{leaveErrors.date && <p className="text-xs text-red-500">{leaveErrors.date}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="leave_notes" className="text-xs md:text-sm">Keterangan (Sakit/Cuti)</Label>
<Textarea
id="leave_notes"
placeholder="Tulis alasan izin..."
required
value={leaveData.notes}
onChange={e => setLeaveData('notes', e.target.value)}
className="resize-none"
rows={4}
/>
{leaveErrors.notes && <p className="text-xs text-red-500">{leaveErrors.notes}</p>}
</div>
<Button type="submit" disabled={processingLeave} className="w-full h-11 rounded-xl bg-sky-600 hover:bg-sky-700">
{processingLeave ? 'Menyimpan...' : 'Submit Izin'}
</Button>
</form>
</TabsContent>
<TabsContent value="dispen" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
<form onSubmit={handleDispenSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dispen_notes" className="text-xs md:text-sm">Tujuan / Keterangan</Label>
<Textarea
id="dispen_notes"
placeholder="Contoh: Meeting dengan klien X di lokasi Y"
required
value={dispenData.notes}
onChange={e => setDispenData('notes', e.target.value)}
className="resize-none"
rows={4}
/>
{dispenErrors.notes && <p className="text-xs text-red-500">{dispenErrors.notes}</p>}
</div>
<Button type="submit" disabled={processingDispen || !coordinates} className="w-full h-11 rounded-xl bg-sky-600 hover:bg-sky-700">
{processingDispen ? 'Menyimpan...' : 'Submit Dispensasi'}
</Button>
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
<div className="lg:col-span-2">
<Card className="border-slate-100 shadow-md h-full overflow-hidden">
<CardHeader className="pb-4 border-b border-slate-100 bg-white">
<CardTitle className="text-lg">Riwayat Absensi</CardTitle>
<CardDescription>Data kehadiran Anda terbaru</CardDescription>
</CardHeader>
<CardContent className="p-0">
{attendances.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
<TableHead className="whitespace-nowrap">Tanggal</TableHead>
<TableHead className="whitespace-nowrap">Clock In</TableHead>
<TableHead className="whitespace-nowrap">Clock Out</TableHead>
<TableHead className="whitespace-nowrap">Status</TableHead>
<TableHead className="whitespace-nowrap min-w-[150px]">Keterangan</TableHead>
<TableHead className="whitespace-nowrap text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attendances.map((att) => (
<TableRow key={att.id}>
<TableCell className="font-medium whitespace-nowrap">{att.date}</TableCell>
<TableCell className="font-mono text-slate-600">{att.check_in || '--:--:--'}</TableCell>
<TableCell className="font-mono text-slate-600">{att.check_out || '--:--:--'}</TableCell>
<TableCell>
<span className="inline-block px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider bg-slate-100 text-slate-600 rounded-md">
{formatStatus(att.status)}
</span>
</TableCell>
<TableCell className="text-slate-500 text-xs">
{att.notes || '-'}
</TableCell>
<TableCell className="text-right">
<AttendanceMapModal
latitudeIn={att.latitude_in}
longitudeIn={att.longitude_in}
latitudeOut={att.latitude_out}
longitudeOut={att.longitude_out}
employeeName="Anda"
date={att.date}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="p-10 text-center">
<p className="text-sm text-slate-500">Belum ada riwayat absensi.</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</EmployeeLayout>
);
}

View File

@ -1,73 +1,116 @@
import { Head } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { usePage } from '@inertiajs/react';
import { User2, Mail, ShieldCheck } from 'lucide-react';
import type { SharedData } from '@/types';
import type { BreadcrumbItem } from '@/types';
import { Head, usePage } from '@inertiajs/react';
import EmployeeLayout from '@/layouts/employee-layout';
import { CheckCircle2, Clock, CalendarDays, ClipboardList, Briefcase, CalendarOff } from 'lucide-react';
import React from 'react';
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Portal Saya', href: '/employee/index' },
];
interface Stats {
present: number;
leave: number;
dispensation: number;
on_time: number;
late: number;
}
export default function EmployeeIndex() {
const { auth } = usePage<SharedData>().props;
interface PageProps {
stats: Stats | null;
employee: any;
[key: string]: any;
}
export default function EmployeeIndex({ stats, employee }: PageProps) {
const { auth } = usePage<any>().props;
const user = auth.user;
const statData = stats || {
present: 0,
leave: 0,
dispensation: 0,
on_time: 0,
late: 0,
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Portal Karyawan" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-8">
{/* Header Sambutan */}
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">
Selamat datang, {user.name}!
</h1>
<p className="text-sm text-muted-foreground">
Ini adalah portal SDM Anda. Pantau informasi akun Anda di sini.
</p>
</div>
{/* Kartu Info Akun */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Nama Lengkap</CardTitle>
<User2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-lg font-semibold">{user.name}</p>
<p className="text-xs text-muted-foreground">Nama akun terdaftar</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Alamat Email</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-lg font-semibold truncate">{user.email}</p>
<p className="text-xs text-muted-foreground">Email untuk login</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Hak Akses</CardTitle>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Badge variant="secondary" className="w-fit capitalize">
{user.role}
</Badge>
<p className="text-xs text-muted-foreground">Role akun Anda saat ini</p>
</CardContent>
</Card>
<EmployeeLayout title="Portal Karyawan">
<div className="bg-gradient-to-r from-sky-400 to-blue-500 text-white md:rounded-b-3xl shadow-sm relative mb-8 md:mb-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-20 md:pb-24">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">Halo, {user.name.split(' ')[0]}!</h1>
<p className="text-sky-100 text-sm md:text-base opacity-90 mt-2">Semoga harimu menyenangkan dan produktif.</p>
</div>
<div className="hidden md:flex w-16 h-16 bg-white/20 backdrop-blur-md rounded-2xl items-center justify-center border border-white/30 shadow-inner">
<img src="/assets/logo-clear.png" alt="Logo" className="w-10 h-10 object-contain drop-shadow-md" onError={(e) => { e.currentTarget.style.display='none'; }} />
</div>
</div>
</div>
</div>
</AppLayout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 md:-mt-24 relative z-10 pb-12">
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 p-5 md:p-8 border border-slate-100 grid grid-cols-1 sm:grid-cols-3 gap-4 md:gap-6 mb-8">
<div className="flex flex-col items-center justify-center p-4 md:p-6 bg-green-100 rounded-xl hover:shadow-md transition-shadow">
<CheckCircle2 className="w-8 h-8 md:w-10 md:h-10 text-green-600 mb-3" />
<span className="text-2xl md:text-3xl font-bold text-green-700">{statData.on_time}</span>
<span className="text-xs md:text-sm font-semibold text-green-700 uppercase tracking-wider mt-2 text-center">Tepat Waktu</span>
</div>
<div className="flex flex-col items-center justify-center p-4 md:p-6 bg-orange-100 rounded-xl hover:shadow-md transition-shadow">
<Clock className="w-8 h-8 md:w-10 md:h-10 text-orange-600 mb-3" />
<span className="text-2xl md:text-3xl font-bold text-orange-700">{statData.leave}</span>
<span className="text-xs md:text-sm font-semibold text-orange-700 uppercase tracking-wider mt-2 text-center">Toleransi/Izin</span>
</div>
<div className="flex flex-col items-center justify-center p-4 md:p-6 bg-red-100 rounded-xl hover:shadow-md transition-shadow">
<CalendarDays className="w-8 h-8 md:w-10 md:h-10 text-red-600 mb-3" />
<span className="text-2xl md:text-3xl font-bold text-red-700">{statData.late}</span>
<span className="text-xs md:text-sm font-semibold text-red-700 uppercase tracking-wider mt-2 text-center">Terlambat</span>
</div>
</div>
<div>
<h2 className="text-base md:text-lg font-bold text-slate-800 tracking-tight mb-4">Statistik Bulan Ini</h2>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 divide-y divide-slate-100 overflow-hidden">
<div className="flex items-center justify-between p-5 hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-sky-50 flex items-center justify-center text-sky-600">
<ClipboardList className="w-6 h-6" />
</div>
<div>
<p className="font-semibold text-slate-700 md:text-base text-sm">Jumlah Presensi</p>
<p className="text-xs md:text-sm text-slate-500">Total kehadiran tercatat</p>
</div>
</div>
<div className="text-xl md:text-2xl font-bold text-sky-600 px-4">{statData.present}</div>
</div>
<div className="flex items-center justify-between p-5 hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center text-purple-600">
<Briefcase className="w-6 h-6" />
</div>
<div>
<p className="font-semibold text-slate-700 md:text-base text-sm">Jumlah Kegiatan</p>
<p className="text-xs md:text-sm text-slate-500">Tugas luar / dispensasi</p>
</div>
</div>
<div className="text-xl md:text-2xl font-bold text-purple-600 px-4">{statData.dispensation}</div>
</div>
<div className="flex items-center justify-between p-5 hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-rose-50 flex items-center justify-center text-rose-600">
<CalendarOff className="w-6 h-6" />
</div>
<div>
<p className="font-semibold text-slate-700 md:text-base text-sm">Jumlah Cuti/Izin</p>
<p className="text-xs md:text-sm text-slate-500">Absen disetujui</p>
</div>
</div>
<div className="text-xl md:text-2xl font-bold text-rose-600 px-4">{statData.leave}</div>
</div>
</div>
</div>
</div>
</EmployeeLayout>
);
}

View File

@ -1,7 +1,6 @@
import { Transition } from '@headlessui/react';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/delete-user';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
@ -143,7 +142,7 @@ export default function Profile({
</Form>
</div>
<DeleteUser />
{/* Delete account section hidden intentionally */}
</SettingsLayout>
</AppLayout>
);

View File

@ -32,9 +32,8 @@
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="icon" href="/assets/logo-clear.png" type="image/png">
<link rel="apple-touch-icon" href="/assets/logo-clear.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

View File

@ -2,17 +2,17 @@
use App\Http\Controllers\Admin\DepartmentController;
use App\Http\Controllers\Admin\EmployeeController;
use App\Http\Controllers\Admin\AttendanceController;
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;
use Illuminate\Support\Facades\Route;
// 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 +24,10 @@
Route::resource('departments', DepartmentController::class);
Route::resource('positions', PositionController::class);
Route::resource('employees', EmployeeController::class);
Route::resource('attendance', AttendanceController::class)->only(['index', 'create', 'store']);
Route::resource('payrolls', PayrollController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']);
Route::patch('payrolls/{payroll}/status', [PayrollController::class, 'updateStatus'])->name('payrolls.updateStatus');
Route::get('attendances', [\App\Http\Controllers\Admin\AttendanceController::class, 'index'])->name('attendances.index');
Route::get('users', [UserController::class, 'index'])->name('users.index');
Route::get('users/create', [UserController::class, 'create'])->name('users.create');
@ -33,9 +37,13 @@
// KELOMPOK KARYAWAN
Route::middleware(['auth', 'verified', 'role:employee'])->group(function () {
Route::get('/employee/index', function () {
return Inertia::render('employee/index');
})->name('employee.index');
Route::get('/employee/index', [\App\Http\Controllers\Employee\EmployeeDashboardController::class, 'index'])->name('employee.index');
Route::get('/employee/attendances', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'index'])->name('employee.attendances.index');
Route::post('/employee/attendances/clock-in', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'clockIn'])->name('employee.attendances.clock-in');
Route::post('/employee/attendances/clock-out', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'clockOut'])->name('employee.attendances.clock-out');
Route::post('/employee/attendances/leave', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'submitLeave'])->name('employee.attendances.leave');
Route::post('/employee/attendances/dispensation', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'submitDispensation'])->name('employee.attendances.dispensation');
});
require __DIR__.'/settings.php';

View File

@ -5,6 +5,9 @@ import laravel from 'laravel-vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '127.0.0.1',
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'],