feat: implement core HRIS features including dashboard, employee management, departments, positions, payroll, and attendance modules
This commit is contained in:
parent
7d4e9fe307
commit
285db59bde
|
|
@ -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']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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}'.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,4 @@
|
|||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
|
|
@ -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='© <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ export function AppSidebar() {
|
|||
{
|
||||
title: 'Manajemen Absensi',
|
||||
href: '/admin/attendance',
|
||||
icon: CalendarCheck2,
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
title: 'Manajemen Gaji',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "Tambah Komponen" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 ── */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 "Telah Dikirim" · {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' })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue