feat: implement core HRIS features including dashboard, employee management, departments, positions, payroll, and attendance modules

This commit is contained in:
IlhamIslamy 2026-05-08 02:02:40 +07:00
parent 7d4e9fe307
commit 285db59bde
46 changed files with 1955 additions and 732 deletions

View File

@ -10,36 +10,52 @@
class AttendanceController extends Controller
{
public function index()
public function index(Request $request)
{
$attendances = Attendance::with('employee')->orderBy('date', 'desc')->get();
$query = Attendance::with('employee')->orderBy('date', 'desc');
$attendances = $attendances->map(function ($attendance) {
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;
// Menghitung selisih jam kerja dalam format H:i
if ($attendance->check_in && $attendance->check_out) {
$checkIn = Carbon::parse($attendance->check_in);
$checkOut = Carbon::parse($attendance->check_out);
$diff = $checkIn->diff($checkOut);
$totalHours = sprintf('%02d:%02d', $diff->h, $diff->i);
}
return [
'id' => $attendance->id,
'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,
'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
'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

@ -30,9 +30,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']),
]);
}
@ -49,26 +49,48 @@ public function store(Request $request)
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8|confirmed',
'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',
'phone_number' => 'nullable|string|max:20',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
], [
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'nip.required' => 'NIP wajib diisi.',
'nip.unique' => 'NIP sudah terdaftar.',
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin hanya boleh Laki-laki atau Perempuan.',
'place_of_birth.max' => 'Tempat lahir maksimal 255 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'phone_number.max' => 'Nomor telepon maksimal 20 karakter.',
'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.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.',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'password' => Hash::make('password'),
'role' => 'employee',
]);
Employee::create([
$employee = Employee::create([
'user_id' => $user->id,
'nip' => $validated['nip'],
'name' => $validated['name'],
@ -83,6 +105,8 @@ public function store(Request $request)
'join_date' => $validated['join_date'],
]);
$user->update(['employee_id' => $employee->id]);
return redirect()->route('admin.employees.index')
->with('success', 'Data karyawan berhasil ditambahkan.');
}
@ -108,11 +132,33 @@ public function update(Request $request, Employee $employee)
'place_of_birth' => 'nullable|string|max:255',
'birth_date' => 'required|date',
'address' => 'nullable|string',
'phone_number' => 'nullable|string',
'phone_number' => 'nullable|string|max:20',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
], [
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'nip.required' => 'NIP wajib diisi.',
'nip.unique' => 'NIP sudah terdaftar.',
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin hanya boleh Laki-laki atau Perempuan.',
'place_of_birth.max' => 'Tempat lahir maksimal 255 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'phone_number.max' => 'Nomor telepon maksimal 20 karakter.',
'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.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.',
]);
$employee->user->update([

View File

@ -11,23 +11,37 @@
class PayrollController extends Controller
{
public function index()
public function index(Request $request)
{
// Eager loading untuk mencegah N+1 query
$payrolls = Payroll::with('employee')
->latest()
->get()
->map(fn($p) => [
'id' => $p->id,
'period' => $p->period,
'net_salary' => $p->net_salary,
'status' => $p->status,
'employee_name' => $p->employee?->name ?? '-',
'employee_nip' => $p->employee?->nip ?? '-',
]);
$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']),
]);
}
@ -70,11 +84,24 @@ public function store(Request $request)
'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_salary = basic_salary + Σbonus Σpotongan, minimal 0
$net = $validated['basic_salary'];
foreach ($validated['details'] ?? [] as $item) {
$net += $item['type'] === 'bonus'
@ -117,4 +144,82 @@ public function show(Payroll $payroll)
],
]);
}
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']),
]);
}
@ -26,6 +31,12 @@ public function store(Request $request)
$validated = $request->validate([
'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);
@ -46,6 +57,12 @@ public function update(Request $request, Position $position)
$validated = $request->validate([
'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

@ -12,18 +12,27 @@
class UserController extends Controller
{
public function index()
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' => User::select('id', 'name', 'email', 'role', 'created_at')
->latest()
->get(),
'users' => $query->latest()->get(),
'filters' => $request->only(['search', 'role']),
]);
}
public function create()
{
// whereDoesntHave mencegah 1 employee memiliki lebih dari 1 akun
// Filter employee tanpa akun
$employees = Employee::whereDoesntHave('user')
->orderBy('name')
->get()
@ -48,7 +57,6 @@ public function store(Request $request)
'unique:users,employee_id',
],
'email' => 'required|email|max:255|unique:users,email',
'password' => 'required|string|min:8|confirmed',
'role' => ['required', Rule::in(['admin', 'employee'])],
], [
'employee_id.required' => 'Karyawan wajib dipilih.',
@ -57,23 +65,22 @@ public function store(Request $request)
'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.',
]);
$employee = Employee::findOrFail($validated['employee_id']);
User::create([
$user = User::create([
'name' => $employee->name,
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'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.");
}
@ -82,6 +89,9 @@ 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);

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

@ -44,6 +44,11 @@ public function clockIn(Request $request)
$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;
@ -79,6 +84,11 @@ public function clockOut(Request $request)
$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;
@ -110,7 +120,13 @@ public function submitLeave(Request $request)
$request->validate([
'date' => 'required|date',
'notes' => 'required|string',
'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([
@ -133,7 +149,15 @@ public function submitDispensation(Request $request)
$request->validate([
'latitude_in' => 'required|numeric',
'longitude_in' => 'required|numeric',
'notes' => 'required|string',
'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();

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

@ -9,7 +9,7 @@
*/
public function up(): void
{
// Duplicate migration kept as a no-op so existing schema is not recreated.
// No-op
}
/**
@ -17,6 +17,6 @@ public function up(): void
*/
public function down(): void
{
// Intentionally no-op.
// No-op
}
};

View File

@ -8,7 +8,7 @@
*/
public function up(): void
{
// Duplicate migration kept as a no-op so existing schema is not recreated.
// No-op
}
/**
@ -16,6 +16,6 @@ public function up(): void
*/
public function down(): void
{
// Intentionally no-op.
// 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

@ -1,30 +1,16 @@
<?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->enum('shift', ['Pagi', 'Siang', 'Malam']);
$table->dateTime('check_in')->nullable();
$table->dateTime('check_out')->nullable();
$table->enum('status', ['hadir', 'izin', 'sakit', 'alpha'])->default('hadir');
$table->string('notes')->nullable();
$table->timestamps();
$table->unique(['employee_id', 'date'], 'attendances_employee_date_unique');
});
// No-op
}
public function down(): void
{
Schema::dropIfExists('attendances');
// No-op
}
};

View File

@ -61,7 +61,8 @@ public function run(): void
foreach ($employees as $data) {
$user = User::create($data['user']);
Employee::create(array_merge($data['employee'], ['user_id' => $user->id]));
$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

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

191
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "HR_Management__App",
"name": "HRIS_App",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -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",
@ -94,7 +99,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -4674,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",
@ -5503,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",
@ -5517,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",
@ -5538,7 +5568,6 @@
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -5548,7 +5577,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -5558,7 +5586,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -5614,7 +5641,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@ -6134,7 +6160,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -6152,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",
@ -6471,7 +6505,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -6564,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",
@ -6627,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",
@ -6694,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",
@ -7284,7 +7351,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7471,7 +7537,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -7822,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",
@ -8803,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",
@ -9583,7 +9663,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -9935,7 +10014,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -9945,7 +10023,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -9957,15 +10034,27 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
"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",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -10096,8 +10185,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -10513,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",
@ -10522,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",
@ -10937,7 +11047,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -11003,7 +11112,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -11163,7 +11271,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -11360,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",
@ -11387,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",
@ -11448,7 +11594,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

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

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

@ -45,7 +45,7 @@ export function AppSidebar() {
{
title: 'Manajemen Absensi',
href: '/admin/attendance',
icon: CalendarCheck2,
icon: CalendarClock,
},
{
title: 'Manajemen Gaji',

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,81 @@
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="/profile" className="p-2 text-slate-500 hover:text-sky-600 transition-colors rounded-full hover:bg-slate-100">
<User className="w-5 h-5" />
</Link>
<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

@ -26,7 +26,7 @@ interface PageProps {
}
export default function Create({ employees }: PageProps) {
const { data, setData, post, processing, errors } = useForm({
const { data, setData, post, processing, errors, transform } = useForm({
employee_id: '',
date: '',
shift: '',
@ -41,14 +41,14 @@ export default function Create({ employees }: PageProps) {
const submit = (e: React.FormEvent) => {
e.preventDefault();
post('/admin/attendance', {
transform: (formData) => ({
...formData,
check_in: formData.check_in || null,
check_out: formData.check_out || null,
notes: formData.notes || null,
}),
});
transform((formData) => ({
...formData,
check_in: formData.check_in || null,
check_out: formData.check_out || null,
notes: formData.notes || null,
}));
post('/admin/attendance');
};
return (

View File

@ -1,9 +1,16 @@
import { Head, usePage } from '@inertiajs/react';
import React from 'react';
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;
@ -15,14 +22,51 @@ interface Attendance {
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 }: PageProps) {
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>;
@ -43,10 +87,50 @@ export default function Index({ attendances }: PageProps) {
</div>
<Card>
<CardHeader className="pb-4">
<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>
@ -57,6 +141,7 @@ export default function Index({ attendances }: PageProps) {
<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>
@ -70,6 +155,16 @@ export default function Index({ attendances }: PageProps) {
<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)}
@ -85,7 +180,7 @@ export default function Index({ attendances }: PageProps) {
))
) : (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
Belum ada data absensi.
</TableCell>
</TableRow>

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,18 +27,36 @@ 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>
@ -46,26 +70,30 @@ export default function Index({ departments }: PageProps) {
<p className="text-muted-foreground">Kelola struktur organisasi dan unit kerja.</p>
</div>
{/* Flash Messages */}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</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 Departemen</CardTitle>
<Button asChild>
<Link href="/admin/departments/create">+ Tambah Departemen</Link>
</Button>
<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

@ -18,8 +18,6 @@ export default function Create({ departments, positions }: PageProps) {
const { data, setData, post, processing, errors } = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
nip: '',
gender: '',
birth_date: '',
@ -85,31 +83,13 @@ export default function Create({ departments, positions }: PageProps) {
{errors.email && <div className="text-red-500 text-xs">{errors.email}</div>}
</div>
<div className="space-y-2">
<Label>Password Login</Label>
<Input
type="password"
value={data.password}
onChange={e => setData('password', e.target.value)}
/>
{errors.password && <div className="text-red-500 text-xs">{errors.password}</div>}
</div>
<div className="space-y-2">
<Label>Konfirmasi Password</Label>
<Input
type="password"
value={data.password_confirmation}
onChange={e => setData('password_confirmation', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>No. Telepon</Label>
<Input
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>
@ -122,6 +102,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>
@ -152,6 +133,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>
@ -244,7 +226,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,7 +8,7 @@ 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;
nip: string;
@ -35,7 +35,7 @@ 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,
@ -109,6 +109,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 +122,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 +153,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 +190,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 +206,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 +220,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 +230,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 +245,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

@ -36,10 +36,7 @@ interface Employee {
}
interface PageProps {
employees: {
data: Employee[];
links: any[];
};
employees: Employee[];
departments: { id: number; name: string }[];
filters: {
search?: string;
@ -48,15 +45,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,6 +65,23 @@ export default function Index({ employees, departments, filters }: PageProps) {
}
}, [debouncedSearch, departmentId]);
const exportExcel = () => {
const rows = employees.map((p, i) => ({
No: i + 1,
Nama: p.user?.name || '-',
Email: p.user?.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" />
@ -85,19 +93,15 @@ export default function Index({ employees, departments, filters }: PageProps) {
<p className="text-muted-foreground">Kelola data seluruh karyawan perusahaan.</p>
</div>
{/* Flash Messages */}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</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 />
@ -142,8 +146,8 @@ 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>

View File

@ -15,7 +15,7 @@ import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
import { PlusCircle, Trash2 } from 'lucide-react';
// ─── Types ───────────────────────────────────────────────────────────────────
interface Position {
id: number;
@ -45,7 +45,7 @@ interface PageProps {
employees: Employee[];
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatRupiah(value: number): string {
return new Intl.NumberFormat('id-ID', {
@ -55,7 +55,7 @@ function formatRupiah(value: number): string {
}).format(value);
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function Create({ employees }: PageProps) {
const { data, setData, post, processing, errors } = useForm<{
@ -72,14 +72,14 @@ export default function Create({ employees }: PageProps) {
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
// Kalkulasi net salary secara real-time di sisi frontend
// 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);
// Auto-fill basic_salary saat employee dipilih
// Set basic_salary otomatis
const handleEmployeeChange = (value: string) => {
const emp = employees.find((e) => String(e.id) === value) ?? null;
setSelectedEmployee(emp);
@ -90,7 +90,7 @@ export default function Create({ employees }: PageProps) {
}));
};
// Tambah baris komponen (bonus/potongan)
// Tambah baris
const addDetail = () => {
setData('details', [
...data.details,
@ -98,7 +98,7 @@ export default function Create({ employees }: PageProps) {
]);
};
// Update baris komponen pada index tertentu
// Update baris
const updateDetail = (index: number, field: keyof DetailItem, value: string | number) => {
const updated = [...data.details];
// @ts-expect-error dynamic field assignment
@ -106,7 +106,7 @@ export default function Create({ employees }: PageProps) {
setData('details', updated);
};
// Hapus baris komponen
// Hapus baris
const removeDetail = (index: number) => {
setData('details', data.details.filter((_, i) => i !== index));
};
@ -278,56 +278,69 @@ export default function Create({ employees }: PageProps) {
</div>
{data.details.map((item, index) => (
<div
key={index}
className="grid grid-cols-[1fr_140px_160px_40px] items-center gap-3"
>
{/* Nama Komponen */}
<Input
placeholder="cth: Bonus Kinerja"
value={item.name}
onChange={(e) =>
updateDetail(index, 'name', e.target.value)
}
/>
{/* Tipe */}
<Select
value={item.type}
onValueChange={(val) =>
updateDetail(index, 'type', val)
}
<div key={index} className="space-y-1">
<div
className="grid grid-cols-[1fr_140px_160px_40px] items-center gap-3"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bonus">Bonus</SelectItem>
<SelectItem value="deduction">Potongan</SelectItem>
</SelectContent>
</Select>
{/* Nama Komponen */}
<Input
placeholder="cth: Bonus Kinerja"
value={item.name}
onChange={(e) =>
updateDetail(index, 'name', e.target.value)
}
/>
{/* Nominal */}
<Input
type="number"
min={0}
placeholder="0"
value={item.amount || ''}
onChange={(e) =>
updateDetail(index, 'amount', 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>
{/* 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>
{/* 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>

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

@ -1,7 +1,15 @@
import { Head, Link } from '@inertiajs/react';
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,
@ -11,7 +19,11 @@ import {
TableRow,
} from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout';
import { PlusCircle, FileText } from 'lucide-react';
import { PlusCircle, FileText, Search, CheckCircle2, Clock, Pencil, Download } from 'lucide-react';
import { useState } from 'react';
import * as XLSX from 'xlsx';
interface Payroll {
id: number;
@ -24,8 +36,12 @@ interface Payroll {
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',
@ -42,41 +58,137 @@ function formatPeriod(period: string): string {
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
return status === 'paid' ? (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">Paid</Badge>
<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">Pending</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 }: PageProps) {
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="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Manajemen Payroll</h2>
<p className="text-muted-foreground">Daftar seluruh payroll yang telah di-generate.</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Slip Gaji</CardTitle>
<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>
<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>
@ -96,28 +208,39 @@ export default function Index({ payrolls }: PageProps) {
<TableBody>
{payrolls.map((payroll, index) => (
<TableRow key={payroll.id}>
<TableCell className="text-muted-foreground">
{index + 1}
</TableCell>
<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>
<div className="text-xs text-muted-foreground">NIP: {payroll.employee_nip}</div>
</TableCell>
<TableCell>{formatPeriod(payroll.period)}</TableCell>
<TableCell className="text-right font-medium">
{formatRupiah(payroll.net_salary)}
</TableCell>
<TableCell className="text-center">
<StatusBadge status={payroll.status} />
<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">
<Button asChild variant="ghost" size="sm">
<Link href={`/admin/payrolls/${payroll.id}`}>
Lihat Slip
</Link>
</Button>
<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>
))}

View File

@ -1,12 +1,13 @@
import { Head, Link } from '@inertiajs/react';
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 } from 'lucide-react';
import { Printer, Building2, CalendarDays, Hash, Pencil, CheckCircle2, Clock } from 'lucide-react';
// ─── Types ───────────────────────────────────────────────────────────────────
interface DetailItem {
name: string;
@ -34,7 +35,7 @@ interface PageProps {
payroll: PayrollData;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatRupiah(value: number): string {
return new Intl.NumberFormat('id-ID', {
@ -50,7 +51,7 @@ function formatPeriod(period: string): string {
return date.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function InfoCell({ label, value }: { label: string; value: string }) {
return (
@ -90,23 +91,36 @@ function LineItem({
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
return status === 'paid' ? (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 border-emerald-200">
Lunas
<Badge 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">
Belum Dibayar
<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>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function Show({ payroll }: PageProps) {
const bonuses = payroll.details.filter((d) => d.type === 'bonus');
const deductions = payroll.details.filter((d) => d.type === 'deduction');
const totalBonus = bonuses.reduce((s, d) => s + d.amount, 0);
const totalDeduction = deductions.reduce((s, d) => s + d.amount, 0);
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>
@ -115,19 +129,45 @@ export default function Show({ payroll }: PageProps) {
<div className="mx-auto max-w-2xl p-4 md:p-8 print:p-0 print:max-w-none">
{/* Toolbar — tersembunyi saat print */}
<div className="mb-5 flex items-center justify-between print:hidden">
<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>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => window.print()}
>
<Printer className="h-3.5 w-3.5" />
Cetak
</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 ── */}

View File

@ -78,7 +78,7 @@ export default function Create() {
<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

@ -85,7 +85,7 @@ export default function Edit({ position }: PageProps) {
<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,18 +21,35 @@ 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>
@ -40,26 +63,30 @@ export default function Index({ positions }: PageProps) {
<p className="text-muted-foreground">Kelola level dan posisi pekerjaan.</p>
</div>
{/* Flash Messages */}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</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 Jabatan</CardTitle>
<Button asChild>
<Link href="/admin/positions/create">+ Tambah Jabatan</Link>
</Button>
<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

@ -14,7 +14,7 @@ import {
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
// ─── Types ───────────────────────────────────────────────────────────────────
interface Employee {
id: number;
@ -27,14 +27,12 @@ interface PageProps {
employees: Employee[];
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function Create({ employees }: PageProps) {
const { data, setData, post, processing, errors } = useForm({
employee_id: '',
email: '',
password: '',
password_confirmation: '',
role: '',
});
@ -125,31 +123,6 @@ export default function Create({ employees }: PageProps) {
)}
</div>
<div className="space-y-2">
<Label>
Password <span className="text-red-500">*</span>
</Label>
<Input
type="password"
placeholder="Minimal 8 karakter"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
/>
{errors.password && (
<p className="text-xs text-red-500">{errors.password}</p>
)}
</div>
<div className="space-y-2">
<Label>
Konfirmasi Password <span className="text-red-500">*</span>
</Label>
<Input
type="password"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
/>
</div>
{/* Role */}
<div className="space-y-2">
@ -170,6 +143,11 @@ export default function Create({ employees }: PageProps) {
)}
</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">

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(
@ -53,27 +84,42 @@ export default function Index({ users }: PageProps) {
<p className="text-muted-foreground">Kelola akun dan hak akses pengguna sistem.</p>
</div>
{/* Flash Messages */}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{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>
)}
{/* Tabel */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Pengguna</CardTitle>
<Button asChild>
<Link href="/admin/users/create">+ Tambah User Baru</Link>
</Button>
<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

@ -36,25 +36,11 @@ export default function Login({ status, canResetPassword }: Props) {
{/* Logo / Nama Aplikasi */}
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-6 w-6 text-primary-foreground"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<div 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">Masuk ke panel manajemen</p>
<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 */}

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

@ -3,13 +3,14 @@ import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import AppLayout from '@/layouts/app-layout';
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';
// Define expected prop types
interface Attendance {
id: number;
date: string;
@ -17,6 +18,10 @@ interface Attendance {
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 {
@ -25,56 +30,31 @@ interface PageProps {
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
error?: string;
};
}
export default function Index({ attendances, todayAttendance }: PageProps) {
const { flash } = usePage<any>().props as SharedData;
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>(false);
const [isLoadingLocation, setIsLoadingLocation] = useState<boolean>(true);
const {
data: regulerData,
setData: setRegulerData,
post: postReguler,
processing: processingReguler
} = useForm({
const { data: regulerData, setData: setRegulerData, post: postReguler, processing: processingReguler } = useForm({
latitude_in: '',
longitude_in: '',
latitude_out: '',
longitude_out: '',
});
const {
data: leaveData,
setData: setLeaveData,
post: postLeave,
processing: processingLeave,
reset: resetLeave
} = useForm({
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
} = useForm({
const { data: dispenData, setData: setDispenData, post: postDispen, processing: processingDispen, reset: resetDispen, errors: dispenErrors } = useForm({
latitude_in: '',
longitude_in: '',
notes: '',
});
// Mendapatkan lokasi saat komponen dimuat atau tombol ditekan
const getLocation = (callback?: (lat: number, lng: number) => void) => {
const getLocation = () => {
setIsLoadingLocation(true);
setLocationError(null);
@ -102,11 +82,10 @@ export default function Index({ attendances, todayAttendance }: PageProps) {
longitude_in: lng.toString(),
});
setIsLoadingLocation(false);
if (callback) callback(lat, lng);
},
(error) => {
let errorMsg = 'Gagal mengambil lokasi.';
if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi.';
if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi di pengaturan browser/perangkat Anda.';
setLocationError(errorMsg);
setIsLoadingLocation(false);
},
@ -120,24 +99,12 @@ export default function Index({ attendances, todayAttendance }: PageProps) {
const handleClockIn = (e: React.FormEvent) => {
e.preventDefault();
if (!coordinates) {
getLocation((lat, lng) => {
postReguler('/employee/attendances/clock-in');
});
} else {
postReguler('/employee/attendances/clock-in');
}
postReguler('/employee/attendances/clock-in');
};
const handleClockOut = (e: React.FormEvent) => {
e.preventDefault();
if (!coordinates) {
getLocation((lat, lng) => {
postReguler('/employee/attendances/clock-out');
});
} else {
postReguler('/employee/attendances/clock-out');
}
postReguler('/employee/attendances/clock-out');
};
const handleLeaveSubmit = (e: React.FormEvent) => {
@ -149,17 +116,9 @@ export default function Index({ attendances, todayAttendance }: PageProps) {
const handleDispenSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!coordinates) {
getLocation((lat, lng) => {
postDispen('/employee/attendances/dispensation', {
onSuccess: () => resetDispen()
});
});
} else {
postDispen('/employee/attendances/dispensation', {
onSuccess: () => resetDispen()
});
}
postDispen('/employee/attendances/dispensation', {
onSuccess: () => resetDispen()
});
};
const formatStatus = (status: string) => {
@ -171,208 +130,215 @@ export default function Index({ attendances, todayAttendance }: PageProps) {
}
};
const isClockInDisabled = processingReguler || isLoadingLocation || !coordinates || Boolean(todayAttendance?.check_in);
const isClockOutDisabled = processingReguler || isLoadingLocation || !coordinates || !todayAttendance || Boolean(todayAttendance?.check_out);
return (
<AppLayout>
<Head title="Absensi Karyawan" />
<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="p-4 md:p-8 max-w-5xl mx-auto space-y-6">
<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">
{/* Header Section */}
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Portal Absensi</h2>
<p className="text-muted-foreground">Lakukan absen masuk/pulang, atau ajukan izin dan dispensasi.</p>
<div className={`p-4 md:p-5 rounded-xl md:rounded-2xl text-sm flex items-start md:items-center gap-3 shadow-sm ${
isLoadingLocation ? 'bg-sky-100 text-sky-800' :
locationError ? 'bg-red-100 text-red-800' :
'bg-green-100 text-green-800'
}`}>
{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>
{/* Flash Messages */}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
{locationError && (
<div className="p-4 bg-yellow-50 text-yellow-700 border border-yellow-200 rounded-md text-sm">
{locationError} <Button variant="link" className="p-0 h-auto font-bold text-yellow-800" onClick={() => getLocation()}>Coba Lagi</Button>
</div>
)}
<div className="grid grid-cols-1 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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column: Forms */}
<div className="md:col-span-1 space-y-6">
<Tabs defaultValue="reguler" className="w-full">
<TabsList className="w-full grid grid-cols-3">
<TabsTrigger value="reguler">Harian</TabsTrigger>
<TabsTrigger value="izin">Izin</TabsTrigger>
<TabsTrigger value="dispen">Dispen</TabsTrigger>
</TabsList>
{/* Tab Absen Reguler */}
<TabsContent value="reguler">
<Card>
<CardHeader>
<CardTitle>Absen Reguler</CardTitle>
<CardDescription>
{todayAttendance
? 'Anda sudah memiliki catatan absensi hari ini.'
: 'Sistem membutuhkan akses lokasi untuk mencatat absensi.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-zinc-50 border rounded-lg text-center space-y-2">
<p className="text-sm text-muted-foreground">Lokasi Saat Ini:</p>
<p className="font-mono text-xs font-semibold">
{isLoadingLocation ? 'Mengambil lokasi...' : (coordinates ? `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}` : 'Lokasi tidak tersedia')}
<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-2">
<div className="grid grid-cols-2 gap-3">
<Button
onClick={handleClockIn}
disabled={processingReguler || (todayAttendance && todayAttendance.check_in !== null)}
className="w-full bg-blue-600 hover:bg-blue-700"
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"
>
Clock In
<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={processingReguler || !todayAttendance || (todayAttendance && todayAttendance.check_out !== null)}
disabled={isClockOutDisabled}
variant="outline"
className="w-full"
className="w-full h-14 md:h-16 flex flex-col gap-1 items-center justify-center rounded-xl border-slate-200 transition-all"
>
Clock Out
<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>
</CardContent>
</Card>
</TabsContent>
</TabsContent>
{/* Tab Izin */}
<TabsContent value="izin">
<Card>
<CardHeader>
<CardTitle>Form Izin / Cuti</CardTitle>
<CardDescription>Ajukan izin tidak masuk kerja.</CardDescription>
</CardHeader>
<CardContent>
<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">Tanggal Izin</Label>
<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">Keterangan (Sakit/Cuti/Dll)</Label>
<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">
Submit Izin
<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>
</CardContent>
</Card>
</TabsContent>
</TabsContent>
{/* Tab Dispensasi */}
<TabsContent value="dispen">
<Card>
<CardHeader>
<CardTitle>Form Dispensasi</CardTitle>
<CardDescription>Dispensasi tugas luar. Memerlukan lokasi.</CardDescription>
</CardHeader>
<CardContent>
<TabsContent value="dispen" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
<form onSubmit={handleDispenSubmit} className="space-y-4">
<div className="p-3 bg-zinc-50 border rounded-lg text-center mb-4">
<p className="text-xs text-muted-foreground">Lokasi Tercatat:</p>
<p className="font-mono text-xs font-semibold">
{isLoadingLocation ? 'Mengambil lokasi...' : (coordinates ? `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}` : 'Lokasi tidak tersedia')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="dispen_notes">Tujuan / Keterangan</Label>
<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} className="w-full">
Submit Dispensasi
<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>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
{/* Right Column: History */}
<div className="md:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>Riwayat Absensi</CardTitle>
<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>
<div className="relative w-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tanggal</TableHead>
<TableHead>Jam Masuk</TableHead>
<TableHead>Jam Keluar</TableHead>
<TableHead>Status</TableHead>
<TableHead>Keterangan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attendances.length > 0 ? (
attendances.map((att) => (
<TableRow key={att.id}>
<TableCell>{att.date}</TableCell>
<TableCell>{att.check_in || '-'}</TableCell>
<TableCell>{att.check_out || '-'}</TableCell>
<TableCell>
<span className="inline-block px-2 py-1 text-xs font-medium bg-gray-100 rounded">
{formatStatus(att.status)}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">
{att.notes || '-'}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
Belum ada riwayat absensi.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<CardContent 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>
</AppLayout>
</EmployeeLayout>
);
}

View File

@ -1,85 +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, flash } = usePage<any>().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>
{/* Flash Messages */}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
<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>
)}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{/* Kartu Info Akun */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<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>
</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

@ -25,7 +25,8 @@
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']);
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');
@ -36,9 +37,7 @@
// KELOMPOK KARYAWAN
Route::middleware(['auth', 'verified', 'role:employee'])->group(function () {
Route::get('/employee/index', function () {
return Inertia\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');