Compare commits
10 Commits
9fb75ac5cd
...
c2cb69924c
| Author | SHA1 | Date |
|---|---|---|
|
|
c2cb69924c | |
|
|
c9eacaee46 | |
|
|
5a7e027ad4 | |
|
|
df88547cc3 | |
|
|
285db59bde | |
|
|
7d4e9fe307 | |
|
|
a3ad24e029 | |
|
|
b9ace087ee | |
|
|
859dbe13a1 | |
|
|
cd50b5b10d |
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Attendance;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Attendance::with('employee')->orderBy('date', 'desc');
|
||||
|
||||
if ($request->search) {
|
||||
$query->whereHas('employee', function ($q) use ($request) {
|
||||
$q->where('name', 'like', '%' . $request->search . '%')
|
||||
->orWhere('nip', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
if ($request->date) {
|
||||
$query->where('date', $request->date);
|
||||
}
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$attendances = $query->get()->map(function ($attendance) {
|
||||
$totalHours = null;
|
||||
if ($attendance->check_in && $attendance->check_out) {
|
||||
$checkIn = Carbon::parse($attendance->check_in);
|
||||
$checkOut = Carbon::parse($attendance->check_out);
|
||||
$diff = $checkIn->diff($checkOut);
|
||||
$totalHours = sprintf('%02d:%02d', $diff->h, $diff->i);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $attendance->id,
|
||||
'employee_name' => $attendance->employee->name ?? '-',
|
||||
'employee_nik' => $attendance->employee->nip ?? '-',
|
||||
'date' => $attendance->date,
|
||||
'check_in' => $attendance->check_in,
|
||||
'check_out' => $attendance->check_out,
|
||||
'status' => $attendance->status,
|
||||
'notes' => $attendance->notes,
|
||||
'total_hours' => $totalHours,
|
||||
'latitude_in' => $attendance->latitude_in,
|
||||
'longitude_in' => $attendance->longitude_in,
|
||||
'latitude_out' => $attendance->latitude_out,
|
||||
'longitude_out'=> $attendance->longitude_out,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('admin/attendances/index', [
|
||||
'attendances' => $attendances,
|
||||
'filters' => $request->only(['search', 'date', 'status']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@
|
|||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEmployeeRequest;
|
||||
use App\Http\Requests\UpdateEmployeeRequest;
|
||||
use App\Models\Department;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class EmployeeController extends Controller
|
||||
|
|
@ -20,9 +19,7 @@ public function index(Request $request)
|
|||
|
||||
if ($request->search) {
|
||||
$query->where('nip', 'like', '%' . $request->search . '%')
|
||||
->orWhereHas('user', function ($q) use ($request) {
|
||||
$q->where('name', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
->orWhere('name', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
if ($request->department_id) {
|
||||
|
|
@ -30,9 +27,9 @@ public function index(Request $request)
|
|||
}
|
||||
|
||||
return Inertia::render('admin/employees/index', [
|
||||
'employees' => $query->latest()->paginate(10)->withQueryString(),
|
||||
'employees' => $query->latest()->get(),
|
||||
'departments' => Department::all(),
|
||||
'filters' => $request->only(['search', 'department_id']),
|
||||
'filters' => $request->only(['search', 'department_id']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -40,46 +37,30 @@ public function create()
|
|||
{
|
||||
return Inertia::render('admin/employees/create', [
|
||||
'departments' => Department::all(),
|
||||
'positions' => Position::all(),
|
||||
'positions' => Position::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
public function store(StoreEmployeeRequest $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'nip' => 'required|string|unique:employees,nip',
|
||||
'gender' => 'required|in:L,P',
|
||||
'place_of_birth' => 'nullable|string|max:255',
|
||||
'birth_date' => 'required|date',
|
||||
'address' => 'nullable|string',
|
||||
'phone_number' => 'nullable|string',
|
||||
'department_id' => 'required|exists:departments,id',
|
||||
'position_id' => 'required|exists:positions,id',
|
||||
'status' => 'required|in:PKWT,PKWTT,Magang',
|
||||
'join_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
$validated = $request->validated();
|
||||
|
||||
// Simpan hanya data karyawan ke tabel employees.
|
||||
// Pembuatan akun User dilakukan secara terpisah oleh admin
|
||||
// melalui menu Manajemen Pengguna.
|
||||
Employee::create([
|
||||
'user_id' => $user->id,
|
||||
'nip' => $validated['nip'],
|
||||
'name' => $validated['name'],
|
||||
'gender' => $validated['gender'],
|
||||
'place_of_birth' => $validated['place_of_birth'],
|
||||
'birth_date' => $validated['birth_date'],
|
||||
'address' => $validated['address'],
|
||||
'phone_number' => $validated['phone_number'],
|
||||
'department_id' => $validated['department_id'],
|
||||
'position_id' => $validated['position_id'],
|
||||
'status' => $validated['status'],
|
||||
'join_date' => $validated['join_date'],
|
||||
'nip' => $validated['nip'],
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'] ?? null,
|
||||
'gender' => $validated['gender'],
|
||||
'place_of_birth' => $validated['place_of_birth'] ?? null,
|
||||
'birth_date' => $validated['birth_date'],
|
||||
'address' => $validated['address'] ?? null,
|
||||
'phone_number' => $validated['phone_number'] ?? null,
|
||||
'department_id' => $validated['department_id'],
|
||||
'position_id' => $validated['position_id'],
|
||||
'status' => $validated['status'],
|
||||
'join_date' => $validated['join_date'],
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.employees.index')
|
||||
|
|
@ -91,43 +72,47 @@ public function edit(Employee $employee)
|
|||
$employee->load(['user', 'department', 'position']);
|
||||
|
||||
return Inertia::render('admin/employees/edit', [
|
||||
'employee' => $employee,
|
||||
'employee' => $employee,
|
||||
'departments' => Department::all(),
|
||||
'positions' => Position::all(),
|
||||
'positions' => Position::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Employee $employee)
|
||||
public function update(UpdateEmployeeRequest $request, Employee $employee)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => ['required', 'email', Rule::unique('users')->ignore($employee->user_id)],
|
||||
'nip' => ['required', 'string', Rule::unique('employees')->ignore($employee->id)],
|
||||
'gender' => 'required|in:L,P',
|
||||
'place_of_birth' => 'nullable|string|max:255',
|
||||
'birth_date' => 'required|date',
|
||||
'address' => 'nullable|string',
|
||||
'phone_number' => 'nullable|string',
|
||||
'department_id' => 'required|exists:departments,id',
|
||||
'position_id' => 'required|exists:positions,id',
|
||||
'status' => 'required|in:PKWT,PKWTT,Magang',
|
||||
'join_date' => 'required|date',
|
||||
]);
|
||||
$validated = $request->validated();
|
||||
|
||||
$employee->user->update([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
]);
|
||||
// Sinkronisasi ke akun User jika karyawan sudah memiliki akun
|
||||
if ($employee->user) {
|
||||
$employee->user->update([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'] ?? $employee->user->email,
|
||||
]);
|
||||
}
|
||||
|
||||
$employee->update($validated);
|
||||
// Update data karyawan di tabel employees
|
||||
$employee->update([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'] ?? null,
|
||||
'nip' => $validated['nip'],
|
||||
'gender' => $validated['gender'],
|
||||
'place_of_birth' => $validated['place_of_birth'] ?? null,
|
||||
'birth_date' => $validated['birth_date'],
|
||||
'address' => $validated['address'] ?? null,
|
||||
'phone_number' => $validated['phone_number'] ?? null,
|
||||
'department_id' => $validated['department_id'],
|
||||
'position_id' => $validated['position_id'],
|
||||
'status' => $validated['status'],
|
||||
'join_date' => $validated['join_date'],
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.employees.index')
|
||||
->with('success', 'Data karyawan diperbarui.');
|
||||
->with('success', 'Data karyawan berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Employee $employee)
|
||||
{
|
||||
$employee->user->delete();
|
||||
return back()->with('success', 'Karyawan dihapus.');
|
||||
$employee->user?->delete();
|
||||
return back()->with('success', 'Karyawan berhasil dihapus.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Payroll::with('employee');
|
||||
|
||||
if ($request->search) {
|
||||
$query->whereHas('employee', function ($q) use ($request) {
|
||||
$q->where('name', 'like', '%' . $request->search . '%')
|
||||
->orWhere('nip', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->period) {
|
||||
$query->where('period', $request->period);
|
||||
}
|
||||
|
||||
if ($request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$payrolls = $query->latest()->get()->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'period' => $p->period,
|
||||
'net_salary' => $p->net_salary,
|
||||
'status' => $p->status,
|
||||
'employee_name' => $p->employee?->name ?? '-',
|
||||
'employee_nip' => $p->employee?->nip ?? '-',
|
||||
]);
|
||||
|
||||
return Inertia::render('admin/payrolls/index', [
|
||||
'payrolls' => $payrolls,
|
||||
'filters' => $request->only(['search', 'period', 'status']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$employees = Employee::with(['position', 'department'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn($e) => [
|
||||
'id' => $e->id,
|
||||
'name' => $e->name,
|
||||
'nip' => $e->nip,
|
||||
'position' => [
|
||||
'id' => $e->position?->id,
|
||||
'name' => $e->position?->name,
|
||||
'basic_salary' => $e->position?->basic_salary ?? 0,
|
||||
],
|
||||
'department' => [
|
||||
'name' => $e->department?->name,
|
||||
],
|
||||
]);
|
||||
|
||||
return Inertia::render('admin/payrolls/create', [
|
||||
'employees' => $employees,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'employee_id' => 'required|exists:employees,id',
|
||||
'period' => [
|
||||
'required',
|
||||
'date_format:Y-m',
|
||||
Rule::unique('payrolls')->where(fn ($query) => $query->where('employee_id', $request->employee_id)),
|
||||
],
|
||||
'basic_salary' => 'required|integer|min:0',
|
||||
'details' => 'nullable|array',
|
||||
'details.*.name' => 'required|string|max:100',
|
||||
'details.*.type' => 'required|in:bonus,deduction',
|
||||
'details.*.amount' => 'required|integer|min:0',
|
||||
], [
|
||||
'employee_id.required' => 'Karyawan wajib dipilih.',
|
||||
'employee_id.exists' => 'Karyawan tidak ditemukan.',
|
||||
'period.required' => 'Periode gaji wajib diisi.',
|
||||
'period.date_format' => 'Format periode harus YYYY-MM.',
|
||||
'period.unique' => 'Payroll karyawan untuk periode ini sudah ada.',
|
||||
'basic_salary.required' => 'Gaji pokok wajib diisi.',
|
||||
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
|
||||
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
|
||||
'details.array' => 'Detail komponen harus berupa array.',
|
||||
'details.*.name.required' => 'Nama komponen wajib diisi.',
|
||||
'details.*.name.max' => 'Nama komponen maksimal 100 karakter.',
|
||||
'details.*.type.required' => 'Tipe komponen wajib dipilih.',
|
||||
'details.*.type.in' => 'Tipe komponen hanya boleh bonus atau potongan.',
|
||||
'details.*.amount.required' => 'Nominal komponen wajib diisi.',
|
||||
'details.*.amount.integer' => 'Nominal komponen harus berupa angka bulat.',
|
||||
'details.*.amount.min' => 'Nominal komponen tidak boleh negatif.',
|
||||
]);
|
||||
|
||||
$net = $validated['basic_salary'];
|
||||
foreach ($validated['details'] ?? [] as $item) {
|
||||
$net += $item['type'] === 'bonus'
|
||||
? $item['amount']
|
||||
: -$item['amount'];
|
||||
}
|
||||
|
||||
Payroll::create([
|
||||
'employee_id' => $validated['employee_id'],
|
||||
'period' => $validated['period'],
|
||||
'basic_salary' => $validated['basic_salary'],
|
||||
'details' => $validated['details'] ?? [],
|
||||
'net_salary' => max(0, $net),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return redirect('/admin/payrolls')
|
||||
->with('success', 'Payroll berhasil di-generate.');
|
||||
}
|
||||
|
||||
public function show(Payroll $payroll)
|
||||
{
|
||||
$payroll->load('employee.position', 'employee.department');
|
||||
|
||||
return Inertia::render('admin/payrolls/show', [
|
||||
'payroll' => [
|
||||
'id' => $payroll->id,
|
||||
'period' => $payroll->period,
|
||||
'basic_salary' => $payroll->basic_salary,
|
||||
'details' => $payroll->details ?? [],
|
||||
'net_salary' => $payroll->net_salary,
|
||||
'status' => $payroll->status,
|
||||
'created_at' => $payroll->created_at->format('d F Y'),
|
||||
'employee' => [
|
||||
'name' => $payroll->employee?->name,
|
||||
'nip' => $payroll->employee?->nip,
|
||||
'position' => $payroll->employee?->position?->name,
|
||||
'department' => $payroll->employee?->department?->name,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Payroll $payroll)
|
||||
{
|
||||
$payroll->load('employee.position', 'employee.department');
|
||||
|
||||
return Inertia::render('admin/payrolls/edit', [
|
||||
'payroll' => [
|
||||
'id' => $payroll->id,
|
||||
'period' => $payroll->period,
|
||||
'basic_salary' => $payroll->basic_salary,
|
||||
'details' => $payroll->details ?? [],
|
||||
'net_salary' => $payroll->net_salary,
|
||||
'status' => $payroll->status,
|
||||
'employee' => [
|
||||
'id' => $payroll->employee?->id,
|
||||
'name' => $payroll->employee?->name,
|
||||
'nip' => $payroll->employee?->nip,
|
||||
'position' => $payroll->employee?->position?->name,
|
||||
'department' => $payroll->employee?->department?->name,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Payroll $payroll)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'basic_salary' => 'required|integer|min:0',
|
||||
'details' => 'nullable|array',
|
||||
'details.*.name' => 'required|string|max:100',
|
||||
'details.*.type' => 'required|in:bonus,deduction',
|
||||
'details.*.amount' => 'required|integer|min:0',
|
||||
], [
|
||||
'basic_salary.required' => 'Gaji pokok wajib diisi.',
|
||||
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
|
||||
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
|
||||
'details.*.name.required' => 'Nama komponen wajib diisi.',
|
||||
'details.*.type.required' => 'Tipe komponen wajib dipilih.',
|
||||
'details.*.type.in' => 'Tipe hanya boleh bonus atau potongan.',
|
||||
'details.*.amount.required' => 'Nominal wajib diisi.',
|
||||
'details.*.amount.min' => 'Nominal tidak boleh negatif.',
|
||||
]);
|
||||
|
||||
$net = $validated['basic_salary'];
|
||||
foreach ($validated['details'] ?? [] as $item) {
|
||||
$net += $item['type'] === 'bonus'
|
||||
? $item['amount']
|
||||
: -$item['amount'];
|
||||
}
|
||||
|
||||
$payroll->update([
|
||||
'basic_salary' => $validated['basic_salary'],
|
||||
'details' => $validated['details'] ?? [],
|
||||
'net_salary' => max(0, $net),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.payrolls.show', $payroll)
|
||||
->with('success', 'Data payroll berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hanya field status payroll (menunggu / telah_dikirim).
|
||||
*/
|
||||
public function updateStatus(Request $request, Payroll $payroll)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', Rule::in(['pending', 'paid'])],
|
||||
], [
|
||||
'status.required' => 'Status wajib dipilih.',
|
||||
'status.in' => 'Status hanya boleh pending atau paid.',
|
||||
]);
|
||||
|
||||
$payroll->update(['status' => $validated['status']]);
|
||||
|
||||
$label = $validated['status'] === 'paid' ? 'Telah Dikirim' : 'Menunggu';
|
||||
|
||||
return back()->with('success', "Status payroll diubah menjadi '{$label}'.");
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,15 @@
|
|||
|
||||
class PositionController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Position::withCount('employees');
|
||||
if ($request->search) {
|
||||
$query->where('name', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
return Inertia::render('admin/positions/index', [
|
||||
'positions' => Position::withCount('employees')->latest()->get(),
|
||||
'positions' => $query->latest()->get(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +29,14 @@ public function create()
|
|||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:positions,name',
|
||||
'name' => 'required|string|max:255|unique:positions,name',
|
||||
'basic_salary' => 'nullable|integer|min:0',
|
||||
], [
|
||||
'name.required' => 'Nama jabatan wajib diisi.',
|
||||
'name.max' => 'Nama jabatan maksimal 255 karakter.',
|
||||
'name.unique' => 'Nama jabatan sudah terdaftar.',
|
||||
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
|
||||
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
|
||||
]);
|
||||
|
||||
Position::create($validated);
|
||||
|
|
@ -43,7 +55,14 @@ public function edit(Position $position)
|
|||
public function update(Request $request, Position $position)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:positions,name,' . $position->id,
|
||||
'name' => 'required|string|max:255|unique:positions,name,' . $position->id,
|
||||
'basic_salary' => 'nullable|integer|min:0',
|
||||
], [
|
||||
'name.required' => 'Nama jabatan wajib diisi.',
|
||||
'name.max' => 'Nama jabatan maksimal 255 karakter.',
|
||||
'name.unique' => 'Nama jabatan sudah terdaftar.',
|
||||
'basic_salary.integer' => 'Gaji pokok harus berupa angka bulat.',
|
||||
'basic_salary.min' => 'Gaji pokok tidak boleh negatif.',
|
||||
]);
|
||||
|
||||
$position->update($validated);
|
||||
|
|
|
|||
|
|
@ -1,68 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return Inertia::render('admin/users/index', [
|
||||
'users' => User::select('id', 'name', 'email', 'role', 'created_at')
|
||||
->latest()
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return Inertia::render('admin/users/create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255|unique:users,email',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
'role' => ['required', Rule::in(['admin', 'employee'])],
|
||||
], [
|
||||
'name.required' => 'Nama wajib diisi.',
|
||||
'email.required' => 'Email wajib diisi.',
|
||||
'email.email' => 'Format email tidak valid.',
|
||||
'email.unique' => 'Email sudah terdaftar.',
|
||||
'password.required' => 'Password wajib diisi.',
|
||||
'password.min' => 'Password minimal 8 karakter.',
|
||||
'password.confirmed' => 'Konfirmasi password tidak cocok.',
|
||||
'role.required' => 'Role wajib dipilih.',
|
||||
'role.in' => 'Role hanya boleh admin atau employee.',
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
'role' => $validated['role'],
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "User {$validated['name']} berhasil ditambahkan.");
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'role' => ['required', Rule::in(['admin', 'employee'])],
|
||||
]);
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return back()->with('success', "Role {$user->name} berhasil diubah.");
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Employee;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = User::select('id', 'name', 'email', 'role', 'created_at');
|
||||
if ($request->search) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'like', '%' . $request->search . '%')
|
||||
->orWhere('email', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
if ($request->role) {
|
||||
$query->where('role', $request->role);
|
||||
}
|
||||
return Inertia::render('admin/users/index', [
|
||||
'users' => $query->latest()->get(),
|
||||
'filters' => $request->only(['search', 'role']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// Filter employee tanpa akun
|
||||
$employees = Employee::whereDoesntHave('user')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn($e) => [
|
||||
'id' => $e->id,
|
||||
'name' => $e->name,
|
||||
'nip' => $e->nip,
|
||||
'position' => $e->position?->name ?? '-',
|
||||
]);
|
||||
|
||||
return Inertia::render('admin/users/create', [
|
||||
'employees' => $employees,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'employee_id' => [
|
||||
'required',
|
||||
'exists:employees,id',
|
||||
'unique:users,employee_id',
|
||||
],
|
||||
'email' => 'required|email|max:255|unique:users,email',
|
||||
'role' => ['required', Rule::in(['admin', 'employee'])],
|
||||
], [
|
||||
'employee_id.required' => 'Karyawan wajib dipilih.',
|
||||
'employee_id.exists' => 'Karyawan tidak ditemukan.',
|
||||
'employee_id.unique' => 'Karyawan ini sudah memiliki akun.',
|
||||
'email.required' => 'Email wajib diisi.',
|
||||
'email.email' => 'Format email tidak valid.',
|
||||
'email.unique' => 'Email sudah terdaftar.',
|
||||
'role.required' => 'Role wajib dipilih.',
|
||||
'role.in' => 'Role hanya boleh admin atau employee.',
|
||||
]);
|
||||
|
||||
$employee = Employee::findOrFail($validated['employee_id']);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $employee->name,
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make('password'),
|
||||
'role' => $validated['role'],
|
||||
'employee_id' => $employee->id,
|
||||
]);
|
||||
|
||||
$employee->update(['user_id' => $user->id]);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "Akun untuk {$employee->name} berhasil dibuat.");
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'role' => ['required', Rule::in(['admin', 'employee'])],
|
||||
], [
|
||||
'role.required' => 'Role wajib dipilih.',
|
||||
'role.in' => 'Role hanya boleh admin atau employee.',
|
||||
]);
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return back()->with('success', "Role {$user->name} berhasil diubah.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Employee;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Attendance;
|
||||
use Illuminate\Http\Request;
|
||||
use Carbon\Carbon;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class EmployeeAttendanceController extends Controller
|
||||
{
|
||||
private function checkEmployee(Request $request)
|
||||
{
|
||||
return $request->user()->employee;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$employee = $this->checkEmployee($request);
|
||||
if (!$employee) {
|
||||
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
|
||||
}
|
||||
|
||||
$employeeId = $employee->id;
|
||||
$attendances = Attendance::where('employee_id', $employeeId)->orderBy('date', 'desc')->get();
|
||||
|
||||
$today = Carbon::today()->toDateString();
|
||||
$todayAttendance = Attendance::where('employee_id', $employeeId)->where('date', $today)->first();
|
||||
|
||||
return Inertia::render('employee/attendances/index', [
|
||||
'attendances' => $attendances,
|
||||
'todayAttendance' => $todayAttendance,
|
||||
]);
|
||||
}
|
||||
|
||||
public function clockIn(Request $request)
|
||||
{
|
||||
$employee = $this->checkEmployee($request);
|
||||
if (!$employee) {
|
||||
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'latitude_in' => 'required|numeric',
|
||||
'longitude_in' => 'required|numeric',
|
||||
], [
|
||||
'latitude_in.required' => 'Lokasi latitude wajib diisi.',
|
||||
'latitude_in.numeric' => 'Lokasi latitude harus berupa angka.',
|
||||
'longitude_in.required' => 'Lokasi longitude wajib diisi.',
|
||||
'longitude_in.numeric' => 'Lokasi longitude harus berupa angka.',
|
||||
]);
|
||||
|
||||
$employeeId = $employee->id;
|
||||
$today = Carbon::today()->toDateString();
|
||||
|
||||
$existingAttendance = Attendance::where('employee_id', $employeeId)
|
||||
->where('date', $today)
|
||||
->first();
|
||||
|
||||
if ($existingAttendance) {
|
||||
return back()->with('error', 'Anda sudah melakukan absen masuk hari ini.');
|
||||
}
|
||||
|
||||
Attendance::create([
|
||||
'employee_id' => $employeeId,
|
||||
'date' => $today,
|
||||
'check_in' => Carbon::now()->toTimeString(),
|
||||
'latitude_in' => $request->latitude_in,
|
||||
'longitude_in' => $request->longitude_in,
|
||||
'status' => 'present',
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Berhasil Absen Masuk.');
|
||||
}
|
||||
|
||||
public function clockOut(Request $request)
|
||||
{
|
||||
$employee = $this->checkEmployee($request);
|
||||
if (!$employee) {
|
||||
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'latitude_out' => 'required|numeric',
|
||||
'longitude_out' => 'required|numeric',
|
||||
], [
|
||||
'latitude_out.required' => 'Lokasi latitude wajib diisi.',
|
||||
'latitude_out.numeric' => 'Lokasi latitude harus berupa angka.',
|
||||
'longitude_out.required' => 'Lokasi longitude wajib diisi.',
|
||||
'longitude_out.numeric' => 'Lokasi longitude harus berupa angka.',
|
||||
]);
|
||||
|
||||
$employeeId = $employee->id;
|
||||
$today = Carbon::today()->toDateString();
|
||||
|
||||
$attendance = Attendance::where('employee_id', $employeeId)
|
||||
->where('date', $today)
|
||||
->first();
|
||||
|
||||
if (!$attendance || $attendance->check_out) {
|
||||
return back()->with('error', 'Anda belum absen masuk atau sudah absen pulang hari ini.');
|
||||
}
|
||||
|
||||
$attendance->update([
|
||||
'check_out' => Carbon::now()->toTimeString(),
|
||||
'latitude_out' => $request->latitude_out,
|
||||
'longitude_out' => $request->longitude_out,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Berhasil Absen Pulang.');
|
||||
}
|
||||
|
||||
public function submitLeave(Request $request)
|
||||
{
|
||||
$employee = $this->checkEmployee($request);
|
||||
if (!$employee) {
|
||||
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'date' => 'required|date',
|
||||
'notes' => 'required|string|min:5',
|
||||
], [
|
||||
'date.required' => 'Tanggal izin wajib diisi.',
|
||||
'date.date' => 'Format tanggal tidak valid.',
|
||||
'notes.required' => 'Keterangan izin wajib diisi.',
|
||||
'notes.string' => 'Keterangan izin harus berupa teks.',
|
||||
'notes.min' => 'Keterangan izin minimal 5 karakter.',
|
||||
]);
|
||||
|
||||
Attendance::create([
|
||||
'employee_id' => $employee->id,
|
||||
'date' => $request->date,
|
||||
'status' => 'leave',
|
||||
'notes' => $request->notes,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Izin berhasil diajukan.');
|
||||
}
|
||||
|
||||
public function submitDispensation(Request $request)
|
||||
{
|
||||
$employee = $this->checkEmployee($request);
|
||||
if (!$employee) {
|
||||
return redirect()->route('employee.index')->with('error', 'Profil karyawan tidak ditemukan.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'latitude_in' => 'required|numeric',
|
||||
'longitude_in' => 'required|numeric',
|
||||
'notes' => 'required|string|min:5',
|
||||
], [
|
||||
'latitude_in.required' => 'Lokasi latitude wajib diisi.',
|
||||
'latitude_in.numeric' => 'Lokasi latitude harus berupa angka.',
|
||||
'longitude_in.required' => 'Lokasi longitude wajib diisi.',
|
||||
'longitude_in.numeric' => 'Lokasi longitude harus berupa angka.',
|
||||
'notes.required' => 'Keterangan dispensasi wajib diisi.',
|
||||
'notes.string' => 'Keterangan dispensasi harus berupa teks.',
|
||||
'notes.min' => 'Keterangan dispensasi minimal 5 karakter.',
|
||||
]);
|
||||
|
||||
$now = Carbon::now();
|
||||
|
||||
Attendance::create([
|
||||
'employee_id' => $employee->id,
|
||||
'date' => $now->toDateString(),
|
||||
'check_in' => $now->toTimeString(),
|
||||
'latitude_in' => $request->latitude_in,
|
||||
'longitude_in' => $request->longitude_in,
|
||||
'status' => 'dispensation',
|
||||
'notes' => $request->notes,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Dispensasi berhasil diajukan.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,10 @@ public function share(Request $request): array
|
|||
'role' => $request->user()->role,
|
||||
] : null,
|
||||
],
|
||||
'flash' => [
|
||||
'success' => fn () => $request->session()->get('success'),
|
||||
'error' => fn () => $request->session()->get('error'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,29 +6,77 @@
|
|||
|
||||
class StoreEmployeeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'nip' => 'required|string|unique:employees,nip',
|
||||
'position' => 'required|string',
|
||||
'status' => 'required|in:PKWT,PKWTT',
|
||||
'join_date' => 'required|date',
|
||||
// ── Identitas ────────────────────────────────────────────────
|
||||
'name' => ['required', 'string', 'max:255', 'regex:/^[\pL\s]+$/u'],
|
||||
'email' => ['nullable', 'email:rfc,dns', 'max:255', 'unique:employees,email'],
|
||||
|
||||
// ── Kepegawaian ──────────────────────────────────────────────
|
||||
'nip' => ['required', 'digits:6', 'unique:employees,nip'],
|
||||
|
||||
// ── Data Pribadi ─────────────────────────────────────────────
|
||||
'gender' => ['required', 'in:L,P'],
|
||||
'place_of_birth'=> ['nullable', 'string', 'max:100'],
|
||||
'birth_date' => ['required', 'date', 'before:today'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'phone_number' => ['nullable', 'digits_between:8,15'],
|
||||
|
||||
// ── Jabatan & Departemen ─────────────────────────────────────
|
||||
'department_id' => ['required', 'exists:departments,id'],
|
||||
'position_id' => ['required', 'exists:positions,id'],
|
||||
|
||||
// ── Status & Tanggal ─────────────────────────────────────────
|
||||
'status' => ['required', 'in:PKWT,PKWTT,Magang'],
|
||||
'join_date' => ['required', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Nama
|
||||
'name.required' => 'Nama lengkap wajib diisi.',
|
||||
'name.max' => 'Nama lengkap maksimal 255 karakter.',
|
||||
'name.regex' => 'Nama lengkap hanya boleh berisi huruf dan spasi.',
|
||||
|
||||
// Email
|
||||
'email.email' => 'Format email tidak valid.',
|
||||
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
|
||||
'email.max' => 'Email maksimal 255 karakter.',
|
||||
|
||||
// NIP
|
||||
'nip.required' => 'NIP wajib diisi.',
|
||||
'nip.digits' => 'NIP harus tepat 6 digit angka.',
|
||||
'nip.unique' => 'NIP sudah terdaftar pada karyawan lain.',
|
||||
|
||||
// Data Pribadi
|
||||
'gender.required' => 'Jenis kelamin wajib dipilih.',
|
||||
'gender.in' => 'Jenis kelamin tidak valid.',
|
||||
'place_of_birth.max' => 'Tempat lahir maksimal 100 karakter.',
|
||||
'birth_date.required' => 'Tanggal lahir wajib diisi.',
|
||||
'birth_date.date' => 'Format tanggal lahir tidak valid.',
|
||||
'birth_date.before' => 'Tanggal lahir harus sebelum hari ini.',
|
||||
'address.max' => 'Alamat maksimal 500 karakter.',
|
||||
'phone_number.digits_between' => 'Nomor HP harus berupa angka, minimal 8 dan maksimal 15 digit.',
|
||||
|
||||
// Jabatan & Departemen
|
||||
'department_id.required'=> 'Departemen wajib dipilih.',
|
||||
'department_id.exists' => 'Departemen tidak ditemukan.',
|
||||
'position_id.required' => 'Jabatan wajib dipilih.',
|
||||
'position_id.exists' => 'Jabatan tidak ditemukan.',
|
||||
|
||||
// Status & Tanggal
|
||||
'status.required' => 'Status kepegawaian wajib dipilih.',
|
||||
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
|
||||
'join_date.required' => 'Tanggal masuk wajib diisi.',
|
||||
'join_date.date' => 'Format tanggal masuk tidak valid.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,36 +3,95 @@
|
|||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateEmployeeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$employeeId = $this->route('employee')->id;
|
||||
$userId = $this->route('employee')->user_id;
|
||||
$employee = $this->route('employee');
|
||||
$employeeId = $employee->id;
|
||||
$userId = $employee->user_id;
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $userId,
|
||||
'nip' => 'required|string|unique:employees,nip,' . $employeeId,
|
||||
'position' => 'required|string',
|
||||
'status' => 'required|in:PKWT,PKWTT',
|
||||
'join_date' => 'required|date',
|
||||
// Password opsional saat update
|
||||
'password' => 'nullable|string|min:8',
|
||||
// ── Identitas ────────────────────────────────────────────────
|
||||
'name' => ['required', 'string', 'max:255', 'regex:/^[\pL\s]+$/u'],
|
||||
'email' => [
|
||||
'nullable',
|
||||
'email:rfc,dns',
|
||||
'max:255',
|
||||
// Abaikan email milik employee ini sendiri
|
||||
Rule::unique('employees', 'email')->ignore($employeeId),
|
||||
],
|
||||
|
||||
// ── Kepegawaian ──────────────────────────────────────────────
|
||||
'nip' => [
|
||||
'required',
|
||||
'digits:6',
|
||||
Rule::unique('employees', 'nip')->ignore($employeeId),
|
||||
],
|
||||
|
||||
// ── Data Pribadi ─────────────────────────────────────────────
|
||||
'gender' => ['required', 'in:L,P'],
|
||||
'place_of_birth'=> ['nullable', 'string', 'max:100'],
|
||||
'birth_date' => ['required', 'date', 'before:today'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'phone_number' => ['nullable', 'digits_between:8,15'],
|
||||
|
||||
// ── Jabatan & Departemen ─────────────────────────────────────
|
||||
'department_id' => ['required', 'exists:departments,id'],
|
||||
'position_id' => ['required', 'exists:positions,id'],
|
||||
|
||||
// ── Status & Tanggal ─────────────────────────────────────────
|
||||
'status' => ['required', 'in:PKWT,PKWTT,Magang'],
|
||||
'join_date' => ['required', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Nama
|
||||
'name.required' => 'Nama lengkap wajib diisi.',
|
||||
'name.max' => 'Nama lengkap maksimal 255 karakter.',
|
||||
'name.regex' => 'Nama lengkap hanya boleh berisi huruf dan spasi.',
|
||||
|
||||
// Email
|
||||
'email.email' => 'Format email tidak valid.',
|
||||
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
|
||||
'email.max' => 'Email maksimal 255 karakter.',
|
||||
|
||||
// NIP
|
||||
'nip.required' => 'NIP wajib diisi.',
|
||||
'nip.digits' => 'NIP harus tepat 6 digit angka.',
|
||||
'nip.unique' => 'NIP sudah terdaftar pada karyawan lain.',
|
||||
|
||||
// Data Pribadi
|
||||
'gender.required' => 'Jenis kelamin wajib dipilih.',
|
||||
'gender.in' => 'Jenis kelamin tidak valid.',
|
||||
'place_of_birth.max' => 'Tempat lahir maksimal 100 karakter.',
|
||||
'birth_date.required' => 'Tanggal lahir wajib diisi.',
|
||||
'birth_date.date' => 'Format tanggal lahir tidak valid.',
|
||||
'birth_date.before' => 'Tanggal lahir harus sebelum hari ini.',
|
||||
'address.max' => 'Alamat maksimal 500 karakter.',
|
||||
'phone_number.digits_between' => 'Nomor HP harus berupa angka, minimal 8 dan maksimal 15 digit.',
|
||||
|
||||
// Jabatan & Departemen
|
||||
'department_id.required'=> 'Departemen wajib dipilih.',
|
||||
'department_id.exists' => 'Departemen tidak ditemukan.',
|
||||
'position_id.required' => 'Jabatan wajib dipilih.',
|
||||
'position_id.exists' => 'Jabatan tidak ditemukan.',
|
||||
|
||||
// Status & Tanggal
|
||||
'status.required' => 'Status kepegawaian wajib dipilih.',
|
||||
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
|
||||
'join_date.required' => 'Tanggal masuk wajib diisi.',
|
||||
'join_date.date' => 'Format tanggal masuk tidak valid.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Attendance extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'employee_id',
|
||||
'date',
|
||||
'check_in',
|
||||
'check_out',
|
||||
'latitude_in',
|
||||
'longitude_in',
|
||||
'latitude_out',
|
||||
'longitude_out',
|
||||
'status',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function employee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Employee::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,10 @@ class Employee extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'user_id', // diisi oleh UserController saat akun digenerate
|
||||
'nip',
|
||||
'name',
|
||||
'email',
|
||||
'gender',
|
||||
'place_of_birth',
|
||||
'birth_date',
|
||||
|
|
@ -21,13 +22,13 @@ class Employee extends Model
|
|||
'department_id',
|
||||
'position_id',
|
||||
'status',
|
||||
'join_date'
|
||||
'join_date',
|
||||
];
|
||||
|
||||
// Relasi ke User
|
||||
// Relasi ke User (nullable: karyawan boleh belum punya akun)
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
return $this->belongsTo(User::class)->withDefault();
|
||||
}
|
||||
|
||||
// Relasi ke Departemen
|
||||
|
|
@ -41,4 +42,9 @@ public function position()
|
|||
{
|
||||
return $this->belongsTo(Position::class);
|
||||
}
|
||||
|
||||
public function attendances()
|
||||
{
|
||||
return $this->hasMany(Attendance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Payroll extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'employee_id',
|
||||
'period',
|
||||
'basic_salary',
|
||||
'details',
|
||||
'net_salary',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'details' => 'array',
|
||||
'basic_salary' => 'integer',
|
||||
'net_salary' => 'integer',
|
||||
];
|
||||
|
||||
public function employee()
|
||||
{
|
||||
return $this->belongsTo(Employee::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,11 @@ class Position extends Model
|
|||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name'];
|
||||
protected $fillable = ['name', 'basic_salary'];
|
||||
|
||||
protected $casts = [
|
||||
'basic_salary' => 'integer',
|
||||
];
|
||||
|
||||
public function employees()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
|
@ -10,26 +9,16 @@
|
|||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
'employee_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'two_factor_secret',
|
||||
|
|
@ -42,22 +31,23 @@ public function employee()
|
|||
return $this->hasOne(Employee::class);
|
||||
}
|
||||
|
||||
// employee_id di kolom users untuk relasi One-to-One langsung
|
||||
public function employeeProfile()
|
||||
{
|
||||
return $this->belongsTo(Employee::class, 'employee_id');
|
||||
}
|
||||
|
||||
public function hasRole($role)
|
||||
{
|
||||
return $this->role === $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_confirmed_at'=> 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustProxies(at: '*');
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->web(append: [
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.3",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^12.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('basic_salary')->default(0)->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->dropColumn('basic_salary');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payrolls', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('employee_id')->constrained()->onDelete('cascade');
|
||||
$table->string('period');
|
||||
$table->unsignedBigInteger('basic_salary');
|
||||
$table->json('details')->nullable();
|
||||
$table->unsignedBigInteger('net_salary');
|
||||
$table->enum('status', ['pending', 'paid'])->default('pending');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payrolls');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignId('employee_id')
|
||||
->nullable()
|
||||
->unique()
|
||||
->constrained('employees')
|
||||
->nullOnDelete()
|
||||
->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['employee_id']);
|
||||
$table->dropColumn('employee_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('payrolls', function (Blueprint $table) {
|
||||
$table->unique(['employee_id', 'period'], 'payrolls_employee_period_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('payrolls', function (Blueprint $table) {
|
||||
$table->dropUnique('payrolls_employee_period_unique');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('attendances', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('employee_id')->constrained()->onDelete('cascade');
|
||||
$table->date('date');
|
||||
$table->time('check_in')->nullable();
|
||||
$table->time('check_out')->nullable();
|
||||
$table->decimal('latitude_in', 10, 8)->nullable();
|
||||
$table->decimal('longitude_in', 11, 8)->nullable();
|
||||
$table->decimal('latitude_out', 10, 8)->nullable();
|
||||
$table->decimal('longitude_out', 11, 8)->nullable();
|
||||
$table->enum('status', ['present', 'leave', 'dispensation'])->default('present');
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('attendances');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Ubah kolom user_id menjadi nullable agar data karyawan
|
||||
* dapat disimpan tanpa harus memiliki akun User terlebih dahulu.
|
||||
* Pembuatan akun User dilakukan secara manual oleh admin
|
||||
* melalui menu Manajemen Pengguna.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('employees', function (Blueprint $table) {
|
||||
// Drop foreign key constraint lama (NOT NULL + cascade delete)
|
||||
$table->dropForeign(['user_id']);
|
||||
|
||||
// Ubah kolom menjadi nullable dan pasang kembali foreign key
|
||||
// dengan nullOnDelete agar baris employee tidak ikut terhapus
|
||||
// jika akun User-nya dihapus.
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->change();
|
||||
|
||||
$table->foreign('user_id')
|
||||
->references('id')
|
||||
->on('users')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback: kembalikan user_id ke NOT NULL dengan cascade delete.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('employees', function (Blueprint $table) {
|
||||
$table->dropForeign(['user_id']);
|
||||
|
||||
$table->foreignId('user_id')
|
||||
->nullable(false)
|
||||
->change();
|
||||
|
||||
$table->foreign('user_id')
|
||||
->references('id')
|
||||
->on('users')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Tambahkan kolom email ke tabel employees.
|
||||
* Sebelumnya email hanya disimpan di tabel users, namun sejak
|
||||
* pembuatan akun User dipisahkan dari proses tambah karyawan,
|
||||
* email perlu ikut disimpan di tabel employees sebagai data kontak.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('employees', function (Blueprint $table) {
|
||||
// Ditempatkan setelah kolom 'name', nullable karena
|
||||
// data karyawan lama mungkin belum memiliki email.
|
||||
$table->string('email')->nullable()->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('employees', function (Blueprint $table) {
|
||||
$table->dropColumn('email');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -9,8 +9,10 @@ class DatabaseSeeder extends Seeder
|
|||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
MasterDataSeeder::class,
|
||||
UserSeeder::class,
|
||||
MasterDataSeeder::class,
|
||||
EmployeeSeeder::class,
|
||||
PayrollSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class EmployeeSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$depts = Department::pluck('id', 'name');
|
||||
$positions = Position::pluck('id', 'name');
|
||||
|
||||
$employees = [
|
||||
[
|
||||
'user' => ['name' => 'Jono Joni', 'email' => 'jono12@gmail.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2023001', 'name' => 'Jono Joni', 'gender' => 'L', 'place_of_birth' => 'Jakarta', 'birth_date' => '1992-05-14', 'address' => 'Jl. Merdeka No.12, Jakarta Pusat', 'phone_number' => '081234567890', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Senior Developer'], 'status' => 'PKWTT', 'join_date' => '2023-01-15'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Budi Santoso', 'email' => 'budi.santoso12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2023002', 'name' => 'Budi Santoso', 'gender' => 'L', 'place_of_birth' => 'Bandung', 'birth_date' => '1995-08-22', 'address' => 'Jl. Sukajadi No.45, Bandung', 'phone_number' => '082345678901', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'PKWT', 'join_date' => '2023-03-01'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Rizky Firmansyah', 'email' => 'rizky.firmansyah12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2024001', 'name' => 'Rizky Firmansyah', 'gender' => 'L', 'place_of_birth' => 'Surabaya', 'birth_date' => '1998-11-30', 'address' => 'Jl. Pemuda No.7, Surabaya', 'phone_number' => '083456789012', 'department_id' => $depts['Information Technology'], 'position_id' => $positions['Junior Developer'], 'status' => 'Magang', 'join_date' => '2024-02-01'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Dewi Rahayu', 'email' => 'dewi.rahayu12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2022001', 'name' => 'Dewi Rahayu', 'gender' => 'P', 'place_of_birth' => 'Yogyakarta', 'birth_date' => '1990-03-10', 'address' => 'Jl. Malioboro No.88, Yogyakarta', 'phone_number' => '084567890123', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['Manager'], 'status' => 'PKWTT', 'join_date' => '2022-06-01'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Anisa Putri', 'email' => 'anisa.putri12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2023003', 'name' => 'Anisa Putri', 'gender' => 'P', 'place_of_birth' => 'Semarang', 'birth_date' => '1997-07-19', 'address' => 'Jl. Pahlawan No.3, Semarang', 'phone_number' => '085678901234', 'department_id' => $depts['Human Resources'], 'position_id' => $positions['HR Specialist'], 'status' => 'PKWTT', 'join_date' => '2023-07-01'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Hendra Kurniawan', 'email' => 'hendra.kurniawan12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2021001', 'name' => 'Hendra Kurniawan', 'gender' => 'L', 'place_of_birth' => 'Medan', 'birth_date' => '1988-12-05', 'address' => 'Jl. Sudirman No.22, Medan', 'phone_number' => '086789012345', 'department_id' => $depts['Finance'], 'position_id' => $positions['Finance Analyst'], 'status' => 'PKWTT', 'join_date' => '2021-04-01'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Sari Wulandari', 'email' => 'sari.wulandari12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2023004', 'name' => 'Sari Wulandari', 'gender' => 'P', 'place_of_birth' => 'Solo', 'birth_date' => '1996-02-28', 'address' => 'Jl. Brigjen Katamso No.5, Solo', 'phone_number' => '087890123456', 'department_id' => $depts['Finance'], 'position_id' => $positions['Staff Admin'], 'status' => 'PKWT', 'join_date' => '2023-09-01'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Fajar Nugroho', 'email' => 'fajar.nugroho12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2022002', 'name' => 'Fajar Nugroho', 'gender' => 'L', 'place_of_birth' => 'Makassar', 'birth_date' => '1993-09-15', 'address' => 'Jl. Sultan Hasanuddin No.10, Makassar', 'phone_number' => '088901234567', 'department_id' => $depts['Marketing'], 'position_id' => $positions['Marketing Staff'], 'status' => 'PKWTT', 'join_date' => '2022-11-01'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Linda Permatasari', 'email' => 'linda.permata12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2024002', 'name' => 'Linda Permatasari', 'gender' => 'P', 'place_of_birth' => 'Palembang', 'birth_date' => '1999-04-20', 'address' => 'Jl. Demang Lebar Daun No.8, Palembang', 'phone_number' => '089012345678', 'department_id' => $depts['Operations'], 'position_id' => $positions['Operations Staff'], 'status' => 'PKWT', 'join_date' => '2024-01-10'],
|
||||
],
|
||||
[
|
||||
'user' => ['name' => 'Agus Prasetyo', 'email' => 'agus.prasetyo12@hris.com', 'password' => Hash::make('password'), 'role' => 'employee'],
|
||||
'employee' => ['nip' => '2020001', 'name' => 'Agus Prasetyo', 'gender' => 'L', 'place_of_birth' => 'Malang', 'birth_date' => '1985-06-10', 'address' => 'Jl. Ijen No.33, Malang', 'phone_number' => '081112233445', 'department_id' => $depts['Operations'], 'position_id' => $positions['Manager'], 'status' => 'PKWTT', 'join_date' => '2020-08-01'],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($employees as $data) {
|
||||
$user = User::create($data['user']);
|
||||
$employee = Employee::create(array_merge($data['employee'], ['user_id' => $user->id]));
|
||||
$user->update(['employee_id' => $employee->id]);
|
||||
}
|
||||
|
||||
$this->command->info('EmployeeSeeder: ' . count($employees) . ' karyawan berhasil dibuat.');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,34 +10,39 @@ class MasterDataSeeder extends Seeder
|
|||
{
|
||||
public function run(): void
|
||||
{
|
||||
// === DEPARTMENTS ===
|
||||
$depts = [
|
||||
// ──────────────────────────────────────────────
|
||||
// DEPARTMENTS
|
||||
// ──────────────────────────────────────────────
|
||||
$departments = [
|
||||
['name' => 'Information Technology', 'description' => 'Bagian IT & Development'],
|
||||
['name' => 'Human Resources', 'description' => 'Bagian Kepegawaian'],
|
||||
['name' => 'Finance', 'description' => 'Bagian Keuangan & Akuntansi'],
|
||||
['name' => 'Marketing', 'description' => 'Bagian Pemasaran & Komunikasi'],
|
||||
['name' => 'Operations', 'description' => 'Bagian Operasional & Logistik'],
|
||||
['name' => 'Human Resources', 'description' => 'Bagian Kepegawaian'],
|
||||
['name' => 'Finance', 'description' => 'Bagian Keuangan & Akuntansi'],
|
||||
['name' => 'Marketing', 'description' => 'Bagian Pemasaran & Komunikasi'],
|
||||
['name' => 'Operations', 'description' => 'Bagian Operasional & Logistik'],
|
||||
];
|
||||
|
||||
foreach ($depts as $dept) {
|
||||
foreach ($departments as $dept) {
|
||||
Department::create($dept);
|
||||
}
|
||||
|
||||
// === POSITIONS ===
|
||||
// ──────────────────────────────────────────────
|
||||
// POSITIONS + GAJI POKOK
|
||||
// ──────────────────────────────────────────────
|
||||
$positions = [
|
||||
'Manager',
|
||||
'Senior Developer',
|
||||
'Junior Developer',
|
||||
'Staff Admin',
|
||||
'HR Specialist',
|
||||
'Finance Analyst',
|
||||
'Marketing Staff',
|
||||
'Operations Staff',
|
||||
['name' => 'Manager', 'basic_salary' => 12_000_000],
|
||||
['name' => 'Senior Developer', 'basic_salary' => 9_000_000],
|
||||
['name' => 'Junior Developer', 'basic_salary' => 5_500_000],
|
||||
['name' => 'Staff Admin', 'basic_salary' => 4_000_000],
|
||||
['name' => 'HR Specialist', 'basic_salary' => 5_800_000],
|
||||
['name' => 'Finance Analyst', 'basic_salary' => 7_000_000],
|
||||
['name' => 'Marketing Staff', 'basic_salary' => 4_500_000],
|
||||
['name' => 'Operations Staff', 'basic_salary' => 4_200_000],
|
||||
];
|
||||
|
||||
foreach ($positions as $pos) {
|
||||
Position::create(['name' => $pos]);
|
||||
Position::create($pos);
|
||||
}
|
||||
|
||||
$this->command->info('MasterDataSeeder: Departments & Positions berhasil dibuat.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PayrollSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Kalkulasi net_salary konsisten dengan logika di PayrollController
|
||||
$net = function (int $basic, array $details): int {
|
||||
$total = $basic;
|
||||
foreach ($details as $item) {
|
||||
$total += $item['type'] === 'bonus' ? $item['amount'] : -$item['amount'];
|
||||
}
|
||||
return max(0, $total);
|
||||
};
|
||||
|
||||
// Eager pluck: 1 query untuk semua employee
|
||||
$emp = Employee::pluck('id', 'name');
|
||||
|
||||
$bulanLalu = now()->subMonth()->format('Y-m');
|
||||
$bulanIni = now()->format('Y-m');
|
||||
|
||||
$records = [];
|
||||
|
||||
if (isset($emp['Jono Joni'])) {
|
||||
$basic = 9_000_000;
|
||||
$detailLalu = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 600_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 450_000],
|
||||
['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 180_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 90_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Jono Joni'], 'period' => $bulanLalu, 'basic_salary' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
|
||||
|
||||
$detailIni = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 600_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 450_000],
|
||||
['name' => 'Bonus Proyek Klien', 'type' => 'bonus', 'amount' => 1_500_000],
|
||||
['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 180_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 90_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Jono Joni'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if (isset($emp['Budi Santoso'])) {
|
||||
$basic = 5_500_000;
|
||||
$detailLalu = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 400_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 350_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 55_000],
|
||||
['name' => 'Potongan Keterlambatan', 'type' => 'deduction', 'amount' => 100_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Budi Santoso'], 'period' => $bulanLalu, 'basic_salary' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
|
||||
|
||||
$detailIni = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 400_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 350_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 55_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Budi Santoso'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if (isset($emp['Rizky Firmansyah'])) {
|
||||
$basic = 5_500_000;
|
||||
$detailIni = [
|
||||
['name' => 'Uang Transport Magang', 'type' => 'bonus', 'amount' => 200_000],
|
||||
['name' => 'Potongan Tidak Hadir', 'type' => 'deduction', 'amount' => 250_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Rizky Firmansyah'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if (isset($emp['Dewi Rahayu'])) {
|
||||
$basic = 12_000_000;
|
||||
$detailLalu = [
|
||||
['name' => 'Tunjangan Jabatan', 'type' => 'bonus', 'amount' => 2_000_000],
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 700_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 500_000],
|
||||
['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 240_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 120_000],
|
||||
['name' => 'Potongan PPh 21', 'type' => 'deduction', 'amount' => 500_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Dewi Rahayu'], 'period' => $bulanLalu, 'basic_salary' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
|
||||
|
||||
$detailIni = [
|
||||
['name' => 'Tunjangan Jabatan', 'type' => 'bonus', 'amount' => 2_000_000],
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 700_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 500_000],
|
||||
['name' => 'Bonus Kinerja Triwulan', 'type' => 'bonus', 'amount' => 1_000_000],
|
||||
['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 240_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 120_000],
|
||||
['name' => 'Potongan PPh 21', 'type' => 'deduction', 'amount' => 600_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Dewi Rahayu'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if (isset($emp['Anisa Putri'])) {
|
||||
$basic = 5_800_000;
|
||||
$detailLalu = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 400_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 350_000],
|
||||
['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 116_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 58_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Anisa Putri'], 'period' => $bulanLalu, 'basic_salary' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
|
||||
}
|
||||
|
||||
if (isset($emp['Hendra Kurniawan'])) {
|
||||
$basic = 7_000_000;
|
||||
$detailLalu = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 500_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 450_000],
|
||||
['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 140_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 70_000],
|
||||
['name' => 'Cicilan Pinjaman', 'type' => 'deduction', 'amount' => 500_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Hendra Kurniawan'], 'period' => $bulanLalu, 'basic_salary' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
|
||||
|
||||
$detailIni = $detailLalu;
|
||||
$records[] = ['employee_id' => $emp['Hendra Kurniawan'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if (isset($emp['Sari Wulandari'])) {
|
||||
$basic = 4_000_000;
|
||||
$detailIni = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 300_000],
|
||||
['name' => 'Potongan Absen', 'type' => 'deduction', 'amount' => 200_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 40_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Sari Wulandari'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if (isset($emp['Fajar Nugroho'])) {
|
||||
$basic = 4_500_000;
|
||||
$detailLalu = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 350_000],
|
||||
['name' => 'Komisi Penjualan', 'type' => 'bonus', 'amount' => 2_000_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 45_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Fajar Nugroho'], 'period' => $bulanLalu, 'basic_salary' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
|
||||
|
||||
$detailIni = [
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 350_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 45_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Fajar Nugroho'], 'period' => $bulanIni, 'basic_salary' => $basic, 'details' => $detailIni, 'net_salary' => $net($basic, $detailIni), 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if (isset($emp['Agus Prasetyo'])) {
|
||||
$basic = 12_000_000;
|
||||
$detailLalu = [
|
||||
['name' => 'Tunjangan Jabatan', 'type' => 'bonus', 'amount' => 2_500_000],
|
||||
['name' => 'Tunjangan Transport', 'type' => 'bonus', 'amount' => 700_000],
|
||||
['name' => 'Tunjangan Makan', 'type' => 'bonus', 'amount' => 500_000],
|
||||
['name' => 'Potongan BPJS Kesehatan', 'type' => 'deduction', 'amount' => 240_000],
|
||||
['name' => 'Potongan BPJS TK', 'type' => 'deduction', 'amount' => 120_000],
|
||||
['name' => 'Potongan PPh 21', 'type' => 'deduction', 'amount' => 650_000],
|
||||
];
|
||||
$records[] = ['employee_id' => $emp['Agus Prasetyo'], 'period' => $bulanLalu, 'basic_salary' => $basic, 'details' => $detailLalu, 'net_salary' => $net($basic, $detailLalu), 'status' => 'paid'];
|
||||
}
|
||||
|
||||
foreach ($records as $data) {
|
||||
Payroll::create($data);
|
||||
}
|
||||
|
||||
$this->command->info('PayrollSeeder: ' . count($records) . ' slip gaji berhasil dibuat.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
<mxfile host="app.diagrams.net" modified="2026-04-21T00:00:00.000Z" agent="GitHub Copilot" version="24.7.17" type="device">
|
||||
<diagram id="flowchart-aplikasi" name="Flowchart Aplikasi HR Management">
|
||||
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2200" pageHeight="1400" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
<mxCell id="2" value="User membuka aplikasi" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="60" width="160" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="/" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="60" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="Redirect ke /login" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="60" width="170" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="Login Fortify" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="60" width="150" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="Login valid?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="825" y="50" width="120" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="Email verified?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="1000" y="50" width="140" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="Role user" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="1200" y="50" width="120" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="9" value="Halaman verifikasi email" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="995" y="180" width="150" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="/dashboard" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="1200" y="180" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="/employee/index" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="1200" y="300" width="140" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="12" value="DashboardController@index" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1400" y="180" width="190" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" value="Role admin?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="1625" y="170" width="120" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="14" value="Dashboard admin" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;" vertex="1" parent="1">
|
||||
<mxGeometry x="1800" y="180" width="150" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="Redirect ke employee.index" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1620" y="300" width="180" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="16" value="Statistik, grafik, latest employees" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1980" y="180" width="190" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="17" value="Departments" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1800" y="320" width="120" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="18" value="Positions" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1960" y="320" width="110" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="19" value="Employees" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="2110" y="320" width="110" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="20" value="Attendance" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1800" y="410" width="120" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="21" value="Payrolls" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1960" y="410" width="110" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" value="Users" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="2110" y="410" width="110" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="23" value="Settings" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
|
||||
<mxGeometry x="1960" y="500" width="110" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="24" value="Profile / Password / Appearance / 2FA" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="1620" y="500" width="260" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="25" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="26" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="3" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="27" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="4" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="28" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="5" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="29" value="ya" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="6" target="7">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="30" value="tidak" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="6" target="5">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="800" y="140" as="sourcePoint"/>
|
||||
<mxPoint x="700" y="140" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="31" value="tidak" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="7" target="9">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="32" value="ya" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="7" target="8">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="33" value="admin" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="8" target="10">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="34" value="employee" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="8" target="11">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1280" y="150"/>
|
||||
<mxPoint x="1280" y="270"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="35" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="10" target="12">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="36" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="12" target="13">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="37" value="ya" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="13" target="14">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="38" value="tidak" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="13" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="39" value="menampilkan ringkasan" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="14" target="16">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="40" value="menu admin" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="14" target="17">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="41" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="17" target="18">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="42" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="18" target="19">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="43" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="19" target="20">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="44" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="20" target="21">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="45" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="21" target="22">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="46" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="22" target="23">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="47" value="" style="endArrow=block;html=1;rounded=0;orthogonalLoop=1;jettySize=auto;" edge="1" parent="1" source="23" target="24">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
# Flowchart Aplikasi HR Management
|
||||
|
||||
Dokumen ini merangkum alur aplikasi berdasarkan route, controller, dan fitur yang saat ini aktif di project.
|
||||
|
||||
## Alur Utama
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User membuka aplikasi] --> B[/ /]
|
||||
B --> C[Redirect ke /login]
|
||||
C --> D[Halaman login Fortify]
|
||||
D --> E{Login valid?}
|
||||
E -- Tidak --> D
|
||||
E -- Ya --> F{Email sudah verified?}
|
||||
F -- Tidak --> G[Halaman verifikasi email]
|
||||
G --> D
|
||||
F -- Ya --> H{Role user}
|
||||
H -- admin --> I[/dashboard]
|
||||
H -- employee --> J[/employee/index]
|
||||
|
||||
I --> K[DashboardController@index]
|
||||
K --> L{Role admin?}
|
||||
L -- Tidak --> J
|
||||
L -- Ya --> M[Dashboard admin]
|
||||
|
||||
M --> N[Statistik total karyawan, departemen, jabatan]
|
||||
M --> O[Grafik gender]
|
||||
M --> P[Grafik departemen]
|
||||
M --> Q[Daftar 5 karyawan terbaru]
|
||||
|
||||
M --> R[Menu admin]
|
||||
R --> S[Departments]
|
||||
R --> T[Positions]
|
||||
R --> U[Employees]
|
||||
R --> V[Attendance]
|
||||
R --> W[Payrolls]
|
||||
R --> X[Users]
|
||||
R --> Y[Settings]
|
||||
|
||||
J --> Z[Halaman employee/index]
|
||||
Z --> Y
|
||||
```
|
||||
|
||||
## Detail Alur per Modul
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph AUTH[Autentikasi]
|
||||
A1[Root /] --> A2[Redirect ke /login]
|
||||
A2 --> A3[Login / Register / Forgot Password / Reset Password]
|
||||
A3 --> A4{Login berhasil?}
|
||||
A4 -- Tidak --> A3
|
||||
A4 -- Ya --> A5{Email verified?}
|
||||
A5 -- Tidak --> A6[Verify email]
|
||||
A6 --> A3
|
||||
A5 -- Ya --> A7[Home /dashboard]
|
||||
end
|
||||
|
||||
subgraph DASH[Dashboard]
|
||||
D1[/dashboard/] --> D2[DashboardController@index]
|
||||
D2 --> D3[Jika role bukan admin, redirect ke employee.index]
|
||||
D2 --> D4[Jika admin, tampilkan ringkasan HR]
|
||||
D4 --> D5[Total employee, department, position]
|
||||
D4 --> D6[Gender chart]
|
||||
D4 --> D7[Department chart]
|
||||
D4 --> D8[Latest employees]
|
||||
end
|
||||
|
||||
subgraph DEPT[Departments]
|
||||
E1[Index] --> E2[Create]
|
||||
E2 --> E3[Store]
|
||||
E1 --> E4[Edit]
|
||||
E4 --> E5[Update]
|
||||
E1 --> E6[Destroy]
|
||||
E6 --> E7{Masih ada karyawan?}
|
||||
E7 -- Ya --> E8[Gagal hapus]
|
||||
E7 -- Tidak --> E9[Departemen terhapus]
|
||||
end
|
||||
|
||||
subgraph POS[Positions]
|
||||
P1[Index] --> P2[Create]
|
||||
P2 --> P3[Store]
|
||||
P1 --> P4[Edit]
|
||||
P4 --> P5[Update]
|
||||
P1 --> P6[Destroy]
|
||||
P6 --> P7{Masih ada karyawan?}
|
||||
P7 -- Ya --> P8[Gagal hapus]
|
||||
P7 -- Tidak --> P9[Jabatan terhapus]
|
||||
end
|
||||
|
||||
subgraph EMP[Employees]
|
||||
M1[Index + filter search dan department]
|
||||
M1 --> M2[Create]
|
||||
M2 --> M3[Isi biodata, departemen, jabatan, akun login]
|
||||
M3 --> M4[Store]
|
||||
M4 --> M5[Create user]
|
||||
M5 --> M6[Create employee]
|
||||
M1 --> M7[Edit]
|
||||
M7 --> M8[Update user dan employee]
|
||||
M1 --> M9[Destroy]
|
||||
M9 --> M10[Delete user terkait]
|
||||
end
|
||||
|
||||
subgraph ATT[Attendance]
|
||||
T1[Index + filter date, status, department, search]
|
||||
T1 --> T2[Create]
|
||||
T2 --> T3[Pilih employee]
|
||||
T3 --> T4[Store]
|
||||
T4 --> T5[Validasi unik per employee per tanggal]
|
||||
end
|
||||
|
||||
subgraph PAY[Payrolls]
|
||||
R1[Index payroll]
|
||||
R1 --> R2[Create]
|
||||
R2 --> R3[Pilih employee dan data salary]
|
||||
R3 --> R4[Store]
|
||||
R4 --> R5[Hitung net salary]
|
||||
R5 --> R6[Simpan status pending]
|
||||
R1 --> R7[Show detail payroll]
|
||||
end
|
||||
|
||||
subgraph USERS[Users]
|
||||
U1[Index user]
|
||||
U1 --> U2[Create]
|
||||
U2 --> U3[Pilih employee yang belum punya akun]
|
||||
U3 --> U4[Store]
|
||||
U4 --> U5[Create user + link employee_id]
|
||||
U1 --> U6[Update role]
|
||||
end
|
||||
|
||||
subgraph SETTING[Settings]
|
||||
S1[Profile] --> S2[Update profile]
|
||||
S1 --> S3[Delete profile]
|
||||
S4[Password] --> S5[Update password]
|
||||
S6[Appearance] --> S7[Theme page]
|
||||
S8[Two factor] --> S9[Two-factor page]
|
||||
end
|
||||
```
|
||||
|
||||
## Ringkasan Urutan Penggunaan
|
||||
|
||||
1. User membuka aplikasi, lalu diarahkan ke halaman login.
|
||||
2. User login melalui Fortify.
|
||||
3. Sistem mengecek verifikasi email.
|
||||
4. Jika role admin, user masuk ke dashboard admin dan mengelola master data, absensi, payroll, dan user akun.
|
||||
5. Jika role employee, user diarahkan ke halaman employee.
|
||||
6. Seluruh user tetap bisa mengakses menu settings sesuai autentikasi dan verifikasi.
|
||||
|
||||
Jika kamu mau, saya bisa lanjut ubah flowchart ini menjadi versi gambar yang lebih rapi untuk presentasi, atau saya buatkan versi khusus per modul dalam satu diagram terpisah.
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
|
|
@ -30,17 +31,21 @@
|
|||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.475.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"use-debounce": "^10.1.0",
|
||||
"vite": "^7.0.4"
|
||||
"vite": "^7.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
|
|
@ -4673,6 +4678,17 @@
|
|||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
|
|
@ -5502,6 +5518,12 @@
|
|||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -5516,6 +5538,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
|
|
@ -6146,6 +6177,15 @@
|
|||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
|
|
@ -6557,6 +6597,19 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -6620,6 +6673,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -6687,6 +6749,18 @@
|
|||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -7813,6 +7887,15 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -8794,6 +8877,12 @@
|
|||
"vite": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
|
@ -9947,6 +10036,20 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
|
|
@ -10498,6 +10601,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -10507,6 +10620,18 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash-x": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
|
||||
|
|
@ -11342,6 +11467,24 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
@ -11369,6 +11512,27 @@
|
|||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -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,5 @@
|
|||
{
|
||||
"php": {
|
||||
"version": "8.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,12 +2,6 @@ import type { SVGAttributes } from 'react';
|
|||
|
||||
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 40 42" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.2 5.63325L8.6 0.855469L0 5.63325V32.1434L16.2 41.1434L32.4 32.1434V23.699L40 19.4767V9.85547L31.4 5.07769L22.8 9.85547V18.2999L17.2 21.411V5.63325ZM38 18.2999L32.4 21.411V15.2545L38 12.1434V18.2999ZM36.9409 10.4439L31.4 13.5221L25.8591 10.4439L31.4 7.36561L36.9409 10.4439ZM24.8 18.2999V12.1434L30.4 15.2545V21.411L24.8 18.2999ZM23.8 20.0323L29.3409 23.1105L16.2 30.411L10.6591 27.3328L23.8 20.0323ZM7.6 27.9212L15.2 32.1434V38.2999L2 30.9666V7.92116L7.6 11.0323V27.9212ZM8.6 9.29991L3.05913 6.22165L8.6 3.14339L14.1409 6.22165L8.6 9.29991ZM30.4 24.8101L17.2 32.1434V38.2999L30.4 30.9666V24.8101ZM9.6 11.0323L15.2 7.92117V22.5221L9.6 25.6333V11.0323Z"
|
||||
/>
|
||||
</svg>
|
||||
<img src="/assets/logo-clear.png" alt="Logo" className="w-8 h-8"/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import AppLogoIcon from './app-logo-icon';
|
|||
export default function AppLogo() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
|
||||
<div className="flex aspect-square size-8 items-center justify-center">
|
||||
<AppLogoIcon className="size-3" />
|
||||
</div>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
SDM App
|
||||
HRIS App
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { LayoutGrid, Users, Briefcase, Building2, SquareUser } from 'lucide-react';
|
||||
import { LayoutGrid, Users, Briefcase, Building2, SquareUser, Wallet, CalendarClock } from 'lucide-react';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
|
|
@ -42,12 +42,29 @@ export function AppSidebar() {
|
|||
href: '/admin/positions',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: 'Manajemen Absensi',
|
||||
href: '/admin/attendance',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
title: 'Manajemen Gaji',
|
||||
href: '/admin/payrolls',
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: 'Manajemen Pengguna',
|
||||
href: '/admin/users',
|
||||
icon: SquareUser,
|
||||
},
|
||||
] : []),
|
||||
...(userRole === 'employee' ? [
|
||||
{
|
||||
title: 'Absensi',
|
||||
href: '/employee/attendances',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
@ -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,78 @@
|
|||
import React from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import { LogOut, Home, CalendarClock, User, Menu } from 'lucide-react';
|
||||
|
||||
interface EmployeeLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function EmployeeLayout({ children, title }: EmployeeLayoutProps) {
|
||||
const user = (usePage<any>().props.auth as any).user;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-900 pb-20 md:pb-0">
|
||||
{title && <Head title={title} />}
|
||||
<Toaster position="top-center" richColors />
|
||||
|
||||
<nav className="hidden md:block bg-white border-b border-slate-200 sticky top-0 z-50 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex-shrink-0 flex items-center gap-3">
|
||||
<img src="/assets/logo-clear.png" alt="Logo" className="h-8 w-auto object-contain" onError={(e) => { e.currentTarget.style.display='none'; }} />
|
||||
<span className="font-bold text-xl text-sky-800 tracking-tight hidden lg:block">HRIS Portal</span>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<Link href="/employee/index" className="px-3 py-2 rounded-md text-sm font-medium text-slate-600 hover:text-sky-600 hover:bg-sky-50 transition-colors flex items-center gap-2">
|
||||
<Home className="w-4 h-4" /> Beranda
|
||||
</Link>
|
||||
<Link href="/employee/attendances" className="px-3 py-2 rounded-md text-sm font-medium text-slate-600 hover:text-sky-600 hover:bg-sky-50 transition-colors flex items-center gap-2">
|
||||
<CalendarClock className="w-4 h-4" /> Absensi
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm font-medium text-slate-700 hidden sm:block">
|
||||
Halo, {user.name}
|
||||
</div>
|
||||
<Link href="/logout" method="post" as="button" className="p-2 text-slate-500 hover:text-red-600 transition-colors rounded-full hover:bg-slate-100">
|
||||
<LogOut className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header className="md:hidden bg-white border-b border-slate-200 sticky top-0 z-40 px-4 h-14 flex items-center justify-between">
|
||||
<img src="/assets/logo-clear.png" alt="Logo" className="h-7 w-auto object-contain" onError={(e) => { e.currentTarget.style.display='none'; }} />
|
||||
<div className="font-semibold text-slate-800 text-sm">HRIS Portal</div>
|
||||
<div className="w-7 h-7"></div>
|
||||
</header>
|
||||
|
||||
<main className="w-full relative">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-slate-200 px-6 py-2 flex justify-between items-center pb-safe shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<Link href="/employee/index" className="flex flex-col items-center gap-1 text-slate-400 hover:text-sky-500 focus:text-sky-500 transition-colors p-1">
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium">Beranda</span>
|
||||
</Link>
|
||||
<Link href="/employee/attendances" className="flex flex-col items-center gap-1 text-slate-400 hover:text-sky-500 focus:text-sky-500 transition-colors p-1">
|
||||
<CalendarClock className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium">Absensi</span>
|
||||
</Link>
|
||||
<Link href="/profile" className="flex flex-col items-center gap-1 text-slate-400 hover:text-sky-500 focus:text-sky-500 transition-colors p-1">
|
||||
<User className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium">Profil</span>
|
||||
</Link>
|
||||
<Link href="/logout" method="post" as="button" className="flex flex-col items-center gap-1 text-slate-400 hover:text-red-500 focus:text-red-500 transition-colors p-1">
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium">Keluar</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import { useCurrentUrl } from '@/hooks/use-current-url';
|
|||
import { cn, toUrl } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { show } from '@/routes/two-factor';
|
||||
import { edit as editPassword } from '@/routes/user-password';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
|
|
@ -22,11 +21,7 @@ const sidebarNavItems: NavItem[] = [
|
|||
href: editPassword(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Two-Factor Auth',
|
||||
href: show(),
|
||||
icon: null,
|
||||
},
|
||||
// Two-Factor Auth nav item is intentionally hidden
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: editAppearance(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
name: string;
|
||||
nip: string;
|
||||
department: string | null;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export default function Create({ employees }: PageProps) {
|
||||
const { data, setData, post, processing, errors, transform } = useForm({
|
||||
employee_id: '',
|
||||
date: '',
|
||||
shift: '',
|
||||
status: 'hadir',
|
||||
check_in: '',
|
||||
check_out: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const selectedEmployee = employees.find((employee) => String(employee.id) === data.employee_id) ?? null;
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
transform((formData) => ({
|
||||
...formData,
|
||||
check_in: formData.check_in || null,
|
||||
check_out: formData.check_out || null,
|
||||
notes: formData.notes || null,
|
||||
}));
|
||||
|
||||
post('/admin/attendance');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Input Absensi" />
|
||||
|
||||
<div className="p-4 md:p-8 max-w-3xl mx-auto">
|
||||
<Button variant="outline" asChild className="mb-6">
|
||||
<Link href="/admin/attendance">Kembali</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Input Absensi Karyawan</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Isi data absensi harian untuk 1 karyawan per tanggal.</p>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Karyawan</Label>
|
||||
<Select value={data.employee_id} onValueChange={(value) => setData('employee_id', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih karyawan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={String(employee.id)}>
|
||||
{employee.name} ({employee.nip})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.employee_id && <p className="text-xs text-red-500">{errors.employee_id}</p>}
|
||||
</div>
|
||||
|
||||
{selectedEmployee && (
|
||||
<div className="rounded-md border bg-muted/40 px-4 py-3 text-sm">
|
||||
<span className="text-muted-foreground">Departemen: </span>
|
||||
<span className="font-medium">{selectedEmployee.department || '-'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tanggal</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={data.date}
|
||||
onChange={(e) => setData('date', e.target.value)}
|
||||
/>
|
||||
{errors.date && <p className="text-xs text-red-500">{errors.date}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Shift</Label>
|
||||
<Select value={data.shift} onValueChange={(value) => setData('shift', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih shift" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Pagi">Pagi</SelectItem>
|
||||
<SelectItem value="Siang">Siang</SelectItem>
|
||||
<SelectItem value="Malam">Malam</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.shift && <p className="text-xs text-red-500">{errors.shift}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Jam Masuk</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={data.check_in}
|
||||
onChange={(e) => setData('check_in', e.target.value)}
|
||||
/>
|
||||
{errors.check_in && <p className="text-xs text-red-500">{errors.check_in}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Jam Pulang</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={data.check_out}
|
||||
onChange={(e) => setData('check_out', e.target.value)}
|
||||
/>
|
||||
{errors.check_out && <p className="text-xs text-red-500">{errors.check_out}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={data.status} onValueChange={(value) => setData('status', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hadir">Hadir</SelectItem>
|
||||
<SelectItem value="izin">Izin</SelectItem>
|
||||
<SelectItem value="sakit">Sakit</SelectItem>
|
||||
<SelectItem value="alpha">Alpha</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.status && <p className="text-xs text-red-500">{errors.status}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Catatan</Label>
|
||||
<Input
|
||||
value={data.notes}
|
||||
onChange={(e) => setData('notes', e.target.value)}
|
||||
placeholder="Opsional"
|
||||
/>
|
||||
{errors.notes && <p className="text-xs text-red-500">{errors.notes}</p>}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan Absensi'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href="/admin/attendance">Batal</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
|
||||
interface Attendance {
|
||||
id: number;
|
||||
date: string;
|
||||
shift: 'Pagi' | 'Siang' | 'Malam';
|
||||
status: 'hadir' | 'izin' | 'sakit' | 'alpha';
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
notes: string | null;
|
||||
employee: {
|
||||
id: number;
|
||||
name: string;
|
||||
nip: string;
|
||||
department: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
attendances: {
|
||||
data: Attendance[];
|
||||
links: any[];
|
||||
};
|
||||
departments: { id: number; name: string }[];
|
||||
filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
date?: string;
|
||||
department_id?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SharedData {
|
||||
flash: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const statusClass: Record<Attendance['status'], string> = {
|
||||
hadir: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100',
|
||||
izin: 'bg-blue-100 text-blue-700 hover:bg-blue-100',
|
||||
sakit: 'bg-amber-100 text-amber-700 hover:bg-amber-100',
|
||||
alpha: 'bg-rose-100 text-rose-700 hover:bg-rose-100',
|
||||
};
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
if (!value) return '-';
|
||||
return new Date(value).toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function Index({ attendances, departments, filters }: PageProps) {
|
||||
const { flash } = usePage<any>().props as SharedData;
|
||||
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [status, setStatus] = useState(filters.status || 'all');
|
||||
const [departmentId, setDepartmentId] = useState(filters.department_id || 'all');
|
||||
const [date, setDate] = useState(filters.date || '');
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
router.get(
|
||||
'/admin/attendance',
|
||||
{
|
||||
search: debouncedSearch || '',
|
||||
status: status === 'all' ? '' : status,
|
||||
department_id: departmentId === 'all' ? '' : departmentId,
|
||||
date,
|
||||
},
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
}, [debouncedSearch, status, departmentId, date]);
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Manajemen Absensi" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Absensi</h2>
|
||||
<p className="text-muted-foreground">Pantau absensi harian karyawan berdasarkan tanggal, status, dan departemen.</p>
|
||||
</div>
|
||||
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flash?.error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
|
||||
{flash.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Absensi</CardTitle>
|
||||
<Button asChild>
|
||||
<Link href="/admin/attendance/create">+ Input Absensi</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Input
|
||||
placeholder="Cari nama / NIP"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filter status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="hadir">Hadir</SelectItem>
|
||||
<SelectItem value="izin">Izin</SelectItem>
|
||||
<SelectItem value="sakit">Sakit</SelectItem>
|
||||
<SelectItem value="alpha">Alpha</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={departmentId} onValueChange={setDepartmentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filter departemen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Departemen</SelectItem>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem key={dept.id} value={String(dept.id)}>
|
||||
{dept.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-zinc-50/50 text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="h-10 px-4 font-medium">Tanggal</th>
|
||||
<th className="h-10 px-4 font-medium">Karyawan</th>
|
||||
<th className="h-10 px-4 font-medium">Shift</th>
|
||||
<th className="h-10 px-4 font-medium">Masuk</th>
|
||||
<th className="h-10 px-4 font-medium">Pulang</th>
|
||||
<th className="h-10 px-4 font-medium">Status</th>
|
||||
<th className="h-10 px-4 font-medium">Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attendances.data.length > 0 ? (
|
||||
attendances.data.map((attendance) => (
|
||||
<tr key={attendance.id} className="border-b hover:bg-zinc-50">
|
||||
<td className="p-4">{formatDate(attendance.date)}</td>
|
||||
<td className="p-4">
|
||||
<div className="font-semibold">{attendance.employee.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
NIP: {attendance.employee.nip} | {attendance.employee.department?.name || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">{attendance.shift}</td>
|
||||
<td className="p-4">{formatTime(attendance.check_in)}</td>
|
||||
<td className="p-4">{formatTime(attendance.check_out)}</td>
|
||||
<td className="p-4">
|
||||
<Badge className={statusClass[attendance.status]}>
|
||||
{attendance.status.toUpperCase()}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4 text-muted-foreground">{attendance.notes || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
||||
Belum ada data absensi.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { Head, router } from '@inertiajs/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { AttendanceMapModal } from '@/components/AttendanceMapModal';
|
||||
|
||||
interface Attendance {
|
||||
id: number;
|
||||
employee_name: string;
|
||||
employee_nik: string;
|
||||
date: string;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
status: 'present' | 'leave' | 'dispensation';
|
||||
notes: string | null;
|
||||
total_hours: string | null;
|
||||
latitude_in?: string | null;
|
||||
longitude_in?: string | null;
|
||||
latitude_out?: string | null;
|
||||
longitude_out?: string | null;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
attendances: Attendance[];
|
||||
filters: { search?: string; date?: string; status?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function Index({ attendances, filters }: PageProps) {
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [date, setDate] = useState(filters.date || '');
|
||||
const [status, setStatus] = useState(filters.status || 'all');
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearch !== filters.search || date !== filters.date || (status !== 'all' && status !== filters.status)) {
|
||||
router.get(
|
||||
'/admin/attendances',
|
||||
{ search: debouncedSearch, date, status: status === 'all' ? '' : status },
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
}
|
||||
}, [debouncedSearch, date, status]);
|
||||
|
||||
const exportExcel = () => {
|
||||
const rows = attendances.map((a, i) => ({
|
||||
No: i + 1,
|
||||
Nama: a.employee_name,
|
||||
NIK: a.employee_nik,
|
||||
Tanggal: a.date,
|
||||
'Jam Masuk': a.check_in || '-',
|
||||
'Jam Keluar': a.check_out || '-',
|
||||
Status: a.status === 'present' ? 'Hadir' : a.status === 'leave' ? 'Izin/Cuti' : 'Dispensasi',
|
||||
'Total Jam': a.total_hours ? `${a.total_hours} Jam` : '-',
|
||||
Catatan: a.notes || '-',
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Absensi');
|
||||
XLSX.writeFile(wb, `Data_Absensi_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
const formatStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'present': return <span className="text-green-600 font-medium">Hadir</span>;
|
||||
case 'leave': return <span className="text-yellow-600 font-medium">Izin/Cuti</span>;
|
||||
case 'dispensation': return <span className="text-blue-600 font-medium">Dispensasi</span>;
|
||||
default: return <span>{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Rekap Absensi" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Rekap Absensi Karyawan</h2>
|
||||
<p className="text-muted-foreground">Monitoring kehadiran, izin, dan dispensasi karyawan.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Absensi</CardTitle>
|
||||
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
|
||||
<div className="p-4 flex flex-col md:flex-row gap-4 items-end">
|
||||
<div className="w-full md:w-1/3 space-y-1">
|
||||
<label className="text-xs text-muted-foreground font-medium">Cari Karyawan</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Nama / NIK..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-1/4 space-y-1">
|
||||
<label className="text-xs text-muted-foreground font-medium">Tanggal</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-1/4 space-y-1">
|
||||
<label className="text-xs text-muted-foreground font-medium">Status</label>
|
||||
<Select value={status} onValueChange={(val) => setStatus(val)}>
|
||||
<SelectTrigger><SelectValue placeholder="Semua Status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="present">Hadir</SelectItem>
|
||||
<SelectItem value="leave">Izin / Cuti</SelectItem>
|
||||
<SelectItem value="dispensation">Dispensasi</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:ml-auto">
|
||||
<Button variant="ghost" onClick={() => { setSearch(''); setDate(''); setStatus('all'); }}>Reset</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nama</TableHead>
|
||||
<TableHead>NIK</TableHead>
|
||||
<TableHead>Tanggal</TableHead>
|
||||
<TableHead>Jam Masuk</TableHead>
|
||||
<TableHead>Jam Keluar</TableHead>
|
||||
<TableHead>Lokasi Peta</TableHead>
|
||||
<TableHead>Keterangan</TableHead>
|
||||
<TableHead className="text-right">Total Jam Kerja</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{attendances.length > 0 ? (
|
||||
attendances.map((attendance) => (
|
||||
<TableRow key={attendance.id}>
|
||||
<TableCell className="font-medium text-gray-900">{attendance.employee_name}</TableCell>
|
||||
<TableCell className="text-gray-600">{attendance.employee_nik}</TableCell>
|
||||
<TableCell className="text-gray-600">{attendance.date}</TableCell>
|
||||
<TableCell>{attendance.check_in || '-'}</TableCell>
|
||||
<TableCell>{attendance.check_out || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<AttendanceMapModal
|
||||
latitudeIn={attendance.latitude_in}
|
||||
longitudeIn={attendance.longitude_in}
|
||||
latitudeOut={attendance.latitude_out}
|
||||
longitudeOut={attendance.longitude_out}
|
||||
employeeName={attendance.employee_name}
|
||||
date={attendance.date}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
{formatStatus(attendance.status)}
|
||||
{attendance.notes && (
|
||||
<span className="text-xs text-gray-500 mt-1">{attendance.notes}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{attendance.total_hours ? `${attendance.total_hours} Jam` : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
|
||||
Belum ada data absensi.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,51 +27,73 @@ interface Department {
|
|||
|
||||
interface PageProps {
|
||||
departments: Department[];
|
||||
filters: { search?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SharedData {
|
||||
flash: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
export default function Index({ departments, filters }: PageProps) {
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
export default function Index({ departments }: PageProps) {
|
||||
const { flash } = usePage<any>().props as SharedData;
|
||||
useEffect(() => {
|
||||
if (debouncedSearch !== filters.search) {
|
||||
router.get(
|
||||
'/admin/departments',
|
||||
{ search: debouncedSearch },
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const exportExcel = () => {
|
||||
const rows = departments.map((d, i) => ({
|
||||
No: i + 1,
|
||||
'Nama Departemen': d.name,
|
||||
Deskripsi: d.description || '-',
|
||||
'Total Karyawan': d.employees_count,
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Departemen');
|
||||
XLSX.writeFile(wb, `Data_Departemen_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Manajemen Departemen" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
{flash?.error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
|
||||
{flash.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Departemen</h2>
|
||||
<p className="text-muted-foreground">Kelola struktur organisasi dan unit kerja.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/departments/create">+ Tambah Departemen</Link>
|
||||
</Button>
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Departemen</h2>
|
||||
<p className="text-muted-foreground">Kelola struktur organisasi dan unit kerja.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Departemen</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin/departments/create">+ Tambah Departemen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
|
||||
<div className="p-4 w-full md:w-1/3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Cari departemen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ export default function Create({ departments, positions }: PageProps) {
|
|||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
type="email"
|
||||
placeholder="contoh@email.com"
|
||||
value={data.email}
|
||||
onChange={e => setData('email', e.target.value)}
|
||||
/>
|
||||
|
|
@ -89,6 +90,7 @@ export default function Create({ departments, positions }: PageProps) {
|
|||
value={data.phone_number}
|
||||
onChange={e => setData('phone_number', e.target.value)}
|
||||
/>
|
||||
{errors.phone_number && <div className="text-red-500 text-xs">{errors.phone_number}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ export default function Create({ departments, positions }: PageProps) {
|
|||
value={data.place_of_birth}
|
||||
onChange={e => setData('place_of_birth', e.target.value)}
|
||||
/>
|
||||
{errors.place_of_birth && <div className="text-red-500 text-xs">{errors.place_of_birth}</div>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tanggal Lahir</Label>
|
||||
|
|
@ -131,6 +134,7 @@ export default function Create({ departments, positions }: PageProps) {
|
|||
value={data.address}
|
||||
onChange={e => setData('address', e.target.value)}
|
||||
/>
|
||||
{errors.address && <div className="text-red-500 text-xs">{errors.address}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -223,7 +227,7 @@ export default function Create({ departments, positions }: PageProps) {
|
|||
<Link href="/admin/employees">Batal</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing} className="px-8">
|
||||
Simpan Data
|
||||
{processing ? 'Menyimpan...' : 'Simpan Data'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
|
||||
// Definisi Tipe Data Employee yang diterima dari Controller
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
name: string;
|
||||
nip: string;
|
||||
gender: string;
|
||||
birth_date: string;
|
||||
|
|
@ -24,7 +25,7 @@ interface Employee {
|
|||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
|
|
@ -35,10 +36,10 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default function Edit({ employee, departments, positions }: PageProps) {
|
||||
// Inisialisasi Form dengan Data Lama (Pre-filled)
|
||||
// Inisialisasi State
|
||||
const { data, setData, put, processing, errors } = useForm({
|
||||
name: employee.user.name,
|
||||
email: employee.user.email,
|
||||
name: employee.name,
|
||||
email: employee.user?.email ?? '',
|
||||
nip: employee.nip,
|
||||
gender: employee.gender,
|
||||
birth_date: employee.birth_date,
|
||||
|
|
@ -58,7 +59,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title={`Edit Karyawan: ${employee.user.name}`} />
|
||||
<Head title={`Edit Karyawan: ${employee.name}`} />
|
||||
|
||||
<div className="p-4 md:p-8 max-w-5xl mx-auto">
|
||||
<Button variant="outline" asChild className="mb-6">
|
||||
|
|
@ -109,6 +110,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
value={data.phone_number}
|
||||
onChange={e => setData('phone_number', e.target.value)}
|
||||
/>
|
||||
{errors.phone_number && <div className="text-red-500 text-xs">{errors.phone_number}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -121,6 +123,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
value={data.place_of_birth}
|
||||
onChange={e => setData('place_of_birth', e.target.value)}
|
||||
/>
|
||||
{errors.place_of_birth && <div className="text-red-500 text-xs">{errors.place_of_birth}</div>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tanggal Lahir</Label>
|
||||
|
|
@ -151,6 +154,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
value={data.address}
|
||||
onChange={e => setData('address', e.target.value)}
|
||||
/>
|
||||
{errors.address && <div className="text-red-500 text-xs">{errors.address}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -187,6 +191,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.department_id && <div className="text-red-500 text-xs">{errors.department_id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -202,6 +207,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.position_id && <div className="text-red-500 text-xs">{errors.position_id}</div>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -215,6 +221,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
<SelectItem value="Magang">Magang</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.status && <div className="text-red-500 text-xs">{errors.status}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -224,6 +231,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
value={data.join_date}
|
||||
onChange={e => setData('join_date', e.target.value)}
|
||||
/>
|
||||
{errors.join_date && <div className="text-red-500 text-xs">{errors.join_date}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -238,7 +246,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
|
|||
<Link href="/admin/employees">Batal</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing} className="px-8">
|
||||
Perbarui Data
|
||||
{processing ? 'Menyimpan...' : 'Perbarui Data'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import AppLayout from '@/layouts/app-layout';
|
|||
|
||||
interface Employee {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
nip: string;
|
||||
status: string;
|
||||
join_date: string;
|
||||
|
|
@ -36,10 +38,7 @@ interface Employee {
|
|||
}
|
||||
|
||||
interface PageProps {
|
||||
employees: {
|
||||
data: Employee[];
|
||||
links: any[];
|
||||
};
|
||||
employees: Employee[];
|
||||
departments: { id: number; name: string }[];
|
||||
filters: {
|
||||
search?: string;
|
||||
|
|
@ -48,15 +47,9 @@ interface PageProps {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SharedData {
|
||||
flash: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export default function Index({ employees, departments, filters }: PageProps) {
|
||||
const { flash } = usePage<any>().props as SharedData;
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [departmentId, setDepartmentId] = useState(filters.department_id || 'all');
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
|
@ -74,22 +67,43 @@ export default function Index({ employees, departments, filters }: PageProps) {
|
|||
}
|
||||
}, [debouncedSearch, departmentId]);
|
||||
|
||||
const exportExcel = () => {
|
||||
const rows = employees.map((p, i) => ({
|
||||
No: i + 1,
|
||||
Nama: p.name || '-',
|
||||
Email: p.email || '-',
|
||||
NIP: p.nip,
|
||||
Departemen: p.department?.name || '-',
|
||||
Jabatan: p.position?.name || '-',
|
||||
Status: p.status,
|
||||
Bergabung: new Date(p.join_date).toLocaleDateString('id-ID'),
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Data Karyawan');
|
||||
XLSX.writeFile(wb, `Data_Karyawan_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Manajemen Karyawan" />
|
||||
<div className="p-4 md:p-8 space-y-4">
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Karyawan</h2>
|
||||
<p className="text-muted-foreground">Kelola data seluruh karyawan perusahaan.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle className="text-xl font-bold">Daftar Karyawan</CardTitle>
|
||||
<Button asChild>
|
||||
<Link href="/admin/employees/create">+ Tambah Karyawan</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin/employees/create">+ Tambah Karyawan</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
|
||||
|
|
@ -134,12 +148,12 @@ export default function Index({ employees, departments, filters }: PageProps) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.data.length > 0 ? (
|
||||
employees.data.map((employee) => (
|
||||
{employees.length > 0 ? (
|
||||
employees.map((employee) => (
|
||||
<tr key={employee.id} className="border-b hover:bg-zinc-50">
|
||||
<td className="p-4">
|
||||
<div className="font-bold">{employee.user?.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{employee.user?.email}</div>
|
||||
<div className="font-bold">{employee.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{employee.email || '-'}</div>
|
||||
<div className="text-xs text-zinc-400 mt-1">NIP: {employee.nip}</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { PlusCircle, Trash2 } from 'lucide-react';
|
||||
|
||||
|
||||
|
||||
interface Position {
|
||||
id: number;
|
||||
name: string;
|
||||
basic_salary: number;
|
||||
}
|
||||
|
||||
interface Department {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
name: string;
|
||||
nip: string;
|
||||
position: Position;
|
||||
department: Department;
|
||||
}
|
||||
|
||||
interface DetailItem {
|
||||
name: string;
|
||||
type: 'bonus' | 'deduction';
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatRupiah(value: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function Create({ employees }: PageProps) {
|
||||
const { data, setData, post, processing, errors } = useForm<{
|
||||
employee_id: string;
|
||||
period: string;
|
||||
basic_salary: number;
|
||||
details: DetailItem[];
|
||||
}>({
|
||||
employee_id: '',
|
||||
period: '',
|
||||
basic_salary: 0,
|
||||
details: [],
|
||||
});
|
||||
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
|
||||
|
||||
// Kalkulasi net salary
|
||||
const netSalary = data.details.reduce((acc, item) => {
|
||||
return item.type === 'bonus'
|
||||
? acc + (item.amount || 0)
|
||||
: acc - (item.amount || 0);
|
||||
}, data.basic_salary);
|
||||
|
||||
// Set basic_salary otomatis
|
||||
const handleEmployeeChange = (value: string) => {
|
||||
const emp = employees.find((e) => String(e.id) === value) ?? null;
|
||||
setSelectedEmployee(emp);
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
employee_id: value,
|
||||
basic_salary: emp?.position?.basic_salary ?? 0,
|
||||
}));
|
||||
};
|
||||
|
||||
// Tambah baris
|
||||
const addDetail = () => {
|
||||
setData('details', [
|
||||
...data.details,
|
||||
{ name: '', type: 'bonus', amount: 0 },
|
||||
]);
|
||||
};
|
||||
|
||||
// Update baris
|
||||
const updateDetail = (index: number, field: keyof DetailItem, value: string | number) => {
|
||||
const updated = [...data.details];
|
||||
// @ts-expect-error – dynamic field assignment
|
||||
updated[index][field] = field === 'amount' ? Number(value) : value;
|
||||
setData('details', updated);
|
||||
};
|
||||
|
||||
// Hapus baris
|
||||
const removeDetail = (index: number) => {
|
||||
setData('details', data.details.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post('/admin/payrolls');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Generate Payroll" />
|
||||
|
||||
<div className="mx-auto max-w-4xl p-4 md:p-8">
|
||||
{/* Back button */}
|
||||
<Button variant="outline" asChild className="mb-6">
|
||||
<Link href="/admin/payrolls">← Kembali</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form Generate Payroll</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pilih karyawan dan periode, lalu tambahkan komponen bonus/potongan jika diperlukan.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-8">
|
||||
|
||||
{/* ── SEKSI 1: Informasi Utama ── */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||||
1
|
||||
</span>
|
||||
Informasi Utama
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Pilih Karyawan */}
|
||||
<div className="space-y-2">
|
||||
<Label>Karyawan</Label>
|
||||
<Select onValueChange={handleEmployeeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih Karyawan..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={String(emp.id)}>
|
||||
{emp.name}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
({emp.nip})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.employee_id && (
|
||||
<p className="text-xs text-red-500">{errors.employee_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Periode */}
|
||||
<div className="space-y-2">
|
||||
<Label>Periode Gaji</Label>
|
||||
<Input
|
||||
type="month"
|
||||
value={data.period}
|
||||
onChange={(e) => setData('period', e.target.value)}
|
||||
/>
|
||||
{errors.period && (
|
||||
<p className="text-xs text-red-500">{errors.period}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Karyawan Terpilih */}
|
||||
{selectedEmployee && (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Departemen:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{selectedEmployee.department.name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Jabatan:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{selectedEmployee.position.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── SEKSI 2: Gaji Pokok (Auto-fill) ── */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||||
2
|
||||
</span>
|
||||
Gaji Pokok
|
||||
</h3>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label>
|
||||
Gaji Pokok{' '}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(otomatis dari jabatan)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.basic_salary}
|
||||
onChange={(e) =>
|
||||
setData('basic_salary', Number(e.target.value))
|
||||
}
|
||||
className="font-medium"
|
||||
/>
|
||||
{errors.basic_salary && (
|
||||
<p className="text-xs text-red-500">{errors.basic_salary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── SEKSI 3: Komponen Bonus / Potongan ── */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
|
||||
3
|
||||
</span>
|
||||
Komponen Gaji
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addDetail}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Tambah Komponen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.details.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Belum ada komponen tambahan. Klik "Tambah Komponen" untuk menambahkan bonus atau potongan.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Header kolom */}
|
||||
<div className="grid grid-cols-[1fr_140px_160px_40px] gap-3 px-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Nama Komponen</span>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tipe</span>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Nominal (Rp)</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{data.details.map((item, index) => (
|
||||
<div key={index} className="space-y-1">
|
||||
<div
|
||||
className="grid grid-cols-[1fr_140px_160px_40px] items-center gap-3"
|
||||
>
|
||||
{/* Nama Komponen */}
|
||||
<Input
|
||||
placeholder="cth: Bonus Kinerja"
|
||||
value={item.name}
|
||||
onChange={(e) =>
|
||||
updateDetail(index, 'name', e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tipe */}
|
||||
<Select
|
||||
value={item.type}
|
||||
onValueChange={(val) =>
|
||||
updateDetail(index, 'type', val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bonus">Bonus</SelectItem>
|
||||
<SelectItem value="deduction">Potongan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Nominal */}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
value={item.amount || ''}
|
||||
onChange={(e) =>
|
||||
updateDetail(index, 'amount', e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Hapus */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeDetail(index)}
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Error per baris detail */}
|
||||
<div className="px-1">
|
||||
{errors[`details.${index}.name`] && (
|
||||
<p className="text-xs text-red-500">{errors[`details.${index}.name`]}</p>
|
||||
)}
|
||||
{errors[`details.${index}.type`] && (
|
||||
<p className="text-xs text-red-500">{errors[`details.${index}.type`]}</p>
|
||||
)}
|
||||
{errors[`details.${index}.amount`] && (
|
||||
<p className="text-xs text-red-500">{errors[`details.${index}.amount`]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── RINGKASAN GAJI BERSIH ── */}
|
||||
<div className="rounded-lg border bg-primary/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Gaji Bersih (Estimasi)</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Gaji Pokok + Bonus − Potongan
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-primary">
|
||||
{formatRupiah(Math.max(0, netSalary))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TOMBOL AKSI ── */}
|
||||
<div className="flex justify-end gap-4 pt-2">
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href="/admin/payrolls">Batal</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || !data.employee_id || !data.period}
|
||||
className="px-8"
|
||||
>
|
||||
{processing ? 'Menyimpan...' : 'Generate Payroll'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { PlusCircle, FileText, Search, CheckCircle2, Clock, Pencil, Download } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
|
||||
interface Payroll {
|
||||
id: number;
|
||||
employee_name: string;
|
||||
employee_nip: string;
|
||||
period: string;
|
||||
net_salary: number;
|
||||
status: 'pending' | 'paid';
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
payrolls: Payroll[];
|
||||
filters: { search?: string; period?: string; status?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatRupiah(value: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPeriod(period: string): string {
|
||||
const [year, month] = period.split('-');
|
||||
return new Date(Number(year), Number(month) - 1, 1)
|
||||
.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
|
||||
return status === 'paid' ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Telah Dikirim
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Menunggu
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function Index({ payrolls, filters }: PageProps) {
|
||||
const [search, setSearch] = useState(filters.search ?? '');
|
||||
const [period, setPeriod] = useState(filters.period ?? '');
|
||||
const [status, setStatus] = useState(filters.status ?? '');
|
||||
const [processingId, setProcessingId] = useState<number | null>(null);
|
||||
|
||||
const applyFilter = () => {
|
||||
router.get('/admin/payrolls', { search, period, status }, { preserveScroll: true });
|
||||
};
|
||||
|
||||
const resetFilter = () => {
|
||||
setSearch(''); setPeriod(''); setStatus('');
|
||||
router.get('/admin/payrolls', {}, { preserveScroll: true });
|
||||
};
|
||||
|
||||
const toggleStatus = (payroll: Payroll) => {
|
||||
const newStatus = payroll.status === 'paid' ? 'pending' : 'paid';
|
||||
setProcessingId(payroll.id);
|
||||
router.patch(
|
||||
`/admin/payrolls/${payroll.id}/status`,
|
||||
{ status: newStatus },
|
||||
{ preserveScroll: true, onFinish: () => setProcessingId(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const exportExcel = () => {
|
||||
const rows = payrolls.map((p, i) => ({
|
||||
No: i + 1,
|
||||
Karyawan: p.employee_name,
|
||||
NIP: p.employee_nip,
|
||||
Periode: formatPeriod(p.period),
|
||||
'Gaji Bersih': p.net_salary,
|
||||
Status: p.status === 'paid' ? 'Telah Dikirim' : 'Menunggu',
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Payroll');
|
||||
XLSX.writeFile(wb, `payroll_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Daftar Payroll" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Payroll</h2>
|
||||
<p className="text-muted-foreground">Daftar seluruh payroll yang telah di-generate.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={exportExcel} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export Excel
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin/payrolls/create" className="gap-2">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Generate Payroll
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="space-y-1 flex-1 min-w-[180px]">
|
||||
<label className="text-xs font-medium text-muted-foreground">Cari Karyawan</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Nama / NIP..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && applyFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 w-[160px]">
|
||||
<label className="text-xs font-medium text-muted-foreground">Periode</label>
|
||||
<Input
|
||||
type="month"
|
||||
value={period}
|
||||
onChange={e => setPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 w-[160px]">
|
||||
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
||||
<Select value={status || 'all'} onValueChange={v => setStatus(v === 'all' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Semua Status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="pending">Menunggu</SelectItem>
|
||||
<SelectItem value="paid">Telah Dikirim</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={applyFilter}>Filter</Button>
|
||||
<Button variant="ghost" onClick={resetFilter}>Reset</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle>Daftar Slip Gaji</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{payrolls.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<FileText className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Belum ada data payroll.</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link href="/admin/payrolls/create">Generate Pertama</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Karyawan</TableHead>
|
||||
<TableHead>Periode</TableHead>
|
||||
<TableHead className="text-right">Gaji Bersih</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-right">Aksi</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payrolls.map((payroll, index) => (
|
||||
<TableRow key={payroll.id}>
|
||||
<TableCell className="text-muted-foreground">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{payroll.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">NIP: {payroll.employee_nip}</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatPeriod(payroll.period)}</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatRupiah(payroll.net_salary)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<button
|
||||
onClick={() => toggleStatus(payroll)}
|
||||
disabled={processingId === payroll.id}
|
||||
className="cursor-pointer disabled:opacity-50"
|
||||
title="Klik untuk ubah status"
|
||||
>
|
||||
<StatusBadge status={payroll.status} />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/admin/payrolls/${payroll.id}/edit`}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/admin/payrolls/${payroll.id}`}>
|
||||
Lihat Slip
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { Printer, Building2, CalendarDays, Hash, Pencil, CheckCircle2, Clock } from 'lucide-react';
|
||||
|
||||
|
||||
|
||||
interface DetailItem {
|
||||
name: string;
|
||||
type: 'bonus' | 'deduction';
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface PayrollData {
|
||||
id: number;
|
||||
period: string;
|
||||
basic_salary: number;
|
||||
details: DetailItem[];
|
||||
net_salary: number;
|
||||
status: 'pending' | 'paid';
|
||||
created_at: string;
|
||||
employee: {
|
||||
name: string;
|
||||
nip: string;
|
||||
position: string;
|
||||
department: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
payroll: PayrollData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatRupiah(value: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPeriod(period: string): string {
|
||||
const [year, month] = period.split('-');
|
||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
||||
return date.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
|
||||
|
||||
function InfoCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineItem({
|
||||
label,
|
||||
value,
|
||||
type = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
type?: 'neutral' | 'bonus' | 'deduction';
|
||||
}) {
|
||||
const valueClass =
|
||||
type === 'bonus'
|
||||
? 'text-emerald-600 font-medium'
|
||||
: type === 'deduction'
|
||||
? 'text-red-500 font-medium'
|
||||
: 'font-medium';
|
||||
const prefix = type === 'bonus' ? '+ ' : type === 'deduction' ? '− ' : '';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className={`text-sm ${valueClass}`}>
|
||||
{prefix}{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'pending' | 'paid' }) {
|
||||
return status === 'paid' ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 border-emerald-200 gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Telah Dikirim
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200 gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Menunggu
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function Show({ payroll }: PageProps) {
|
||||
const bonuses = payroll.details.filter((d) => d.type === 'bonus');
|
||||
const deductions = payroll.details.filter((d) => d.type === 'deduction');
|
||||
const totalBonus = bonuses.reduce((s, d) => s + d.amount, 0);
|
||||
const totalDeduction = deductions.reduce((s, d) => s + d.amount, 0);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const toggleStatus = () => {
|
||||
const newStatus = payroll.status === 'paid' ? 'pending' : 'paid';
|
||||
setToggling(true);
|
||||
router.patch(
|
||||
`/admin/payrolls/${payroll.id}/status`,
|
||||
{ status: newStatus },
|
||||
{ onFinish: () => setToggling(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title={`Slip Gaji — ${payroll.employee.name}`} />
|
||||
|
||||
<div className="mx-auto max-w-2xl p-4 md:p-8 print:p-0 print:max-w-none">
|
||||
|
||||
{/* Toolbar — tersembunyi saat print */}
|
||||
<div className="mb-5 flex items-center justify-between gap-2 print:hidden">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/payrolls">← Kembali</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{payroll.status !== 'paid' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleStatus}
|
||||
disabled={toggling}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> Tandai Terkirim
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/admin/payrolls/${payroll.id}/edit`}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Cetak
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Slip Card ── */}
|
||||
<Card className="overflow-hidden border shadow-sm print:shadow-none print:border-none print:rounded-none">
|
||||
|
||||
{/* ── HEADER ── */}
|
||||
<div className="border-b bg-zinc-50 px-6 py-5 print:break-inside-avoid">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Nama Perusahaan */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold leading-none text-foreground">
|
||||
PT. HRIS Nusantara
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Slip Gaji Karyawan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Meta slip */}
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<Hash className="h-3 w-3" />
|
||||
<span className="font-mono font-medium">
|
||||
{String(payroll.id).padStart(5, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
<span>{payroll.created_at}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<StatusBadge status={payroll.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
|
||||
{/* ── INFO KARYAWAN ── */}
|
||||
<div className="border-b px-6 py-5 print:break-inside-avoid">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Informasi Karyawan
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
||||
<InfoCell label="Nama" value={payroll.employee.name} />
|
||||
<InfoCell label="NIP" value={payroll.employee.nip} />
|
||||
<InfoCell label="Jabatan" value={payroll.employee.position ?? '-'} />
|
||||
<InfoCell label="Departemen" value={payroll.employee.department ?? '-'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── PERIODE ── */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-6 py-3 print:break-inside-avoid">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Periode Penggajian
|
||||
</p>
|
||||
<p className="text-sm font-bold text-foreground">
|
||||
{formatPeriod(payroll.period)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── RINCIAN GAJI ── */}
|
||||
<div className="px-6 py-5 space-y-1 print:break-inside-avoid">
|
||||
|
||||
{/* Gaji Pokok */}
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Komponen Gaji
|
||||
</p>
|
||||
<LineItem
|
||||
label="Gaji Pokok"
|
||||
value={formatRupiah(payroll.basic_salary)}
|
||||
/>
|
||||
|
||||
{/* Bonus */}
|
||||
{bonuses.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<p className="pb-0.5 text-[11px] font-semibold uppercase tracking-widest text-emerald-600">
|
||||
Tambahan
|
||||
</p>
|
||||
{bonuses.map((item, i) => (
|
||||
<LineItem
|
||||
key={i}
|
||||
label={item.name}
|
||||
value={formatRupiah(item.amount)}
|
||||
type="bonus"
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-between pt-1 text-xs">
|
||||
<span className="text-muted-foreground">Subtotal tambahan</span>
|
||||
<span className="font-semibold text-emerald-600">
|
||||
+ {formatRupiah(totalBonus)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Potongan */}
|
||||
{deductions.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<p className="pb-0.5 text-[11px] font-semibold uppercase tracking-widest text-red-500">
|
||||
Potongan
|
||||
</p>
|
||||
{deductions.map((item, i) => (
|
||||
<LineItem
|
||||
key={i}
|
||||
label={item.name}
|
||||
value={formatRupiah(item.amount)}
|
||||
type="deduction"
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-between pt-1 text-xs">
|
||||
<span className="text-muted-foreground">Subtotal potongan</span>
|
||||
<span className="font-semibold text-red-500">
|
||||
− {formatRupiah(totalDeduction)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── TOTAL GAJI BERSIH ── */}
|
||||
<div className="border-t bg-zinc-50 px-6 py-5 print:break-inside-avoid">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Total Gaji Bersih
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gaji Pokok
|
||||
{bonuses.length > 0 && ' + Tambahan'}
|
||||
{deductions.length > 0 && ' − Potongan'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{formatRupiah(payroll.net_salary)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── FOOTER ── */}
|
||||
<div className="border-t px-6 py-3">
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Dokumen ini diterbitkan secara otomatis oleh sistem HRIS dan sah tanpa tanda tangan basah.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import AppLayout from '@/layouts/app-layout';
|
|||
export default function Create() {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
name: '',
|
||||
basic_salary: '',
|
||||
});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
|
|
@ -20,7 +21,7 @@ export default function Create() {
|
|||
return (
|
||||
<AppLayout>
|
||||
<Head title="Tambah Jabatan" />
|
||||
|
||||
|
||||
<div className="p-4 md:p-8 w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
|
|
@ -39,20 +40,45 @@ export default function Create() {
|
|||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
|
||||
<div className="max-w-xl space-y-2">
|
||||
<Label>Nama Jabatan <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="Contoh: Senior Backend Developer"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
|
||||
|
||||
<div className="max-w-xl space-y-4">
|
||||
{/* Nama Jabatan */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Nama Jabatan <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Contoh: Senior Backend Developer"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-xs">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gaji Pokok */}
|
||||
<div className="space-y-2">
|
||||
<Label>Gaji Pokok (Rp)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Contoh: 5000000"
|
||||
value={data.basic_salary}
|
||||
onChange={e => setData('basic_salary', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nilai ini akan otomatis terisi saat membuat Payroll untuk karyawan dengan jabatan ini.
|
||||
</p>
|
||||
{errors.basic_salary && (
|
||||
<p className="text-red-500 text-xs">{errors.basic_salary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start gap-4 pt-4">
|
||||
<div className="flex justify-start gap-4 pt-2">
|
||||
<Button type="submit" disabled={processing}>
|
||||
Simpan Jabatan
|
||||
{processing ? 'Menyimpan...' : 'Simpan Jabatan'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href="/admin/positions">Batal</Link>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import AppLayout from '@/layouts/app-layout';
|
|||
interface Position {
|
||||
id: number;
|
||||
name: string;
|
||||
basic_salary: number | null;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
|
|
@ -19,6 +20,7 @@ interface PageProps {
|
|||
export default function Edit({ position }: PageProps) {
|
||||
const { data, setData, put, processing, errors } = useForm({
|
||||
name: position.name,
|
||||
basic_salary: position.basic_salary ?? '',
|
||||
});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
|
|
@ -29,12 +31,12 @@ export default function Edit({ position }: PageProps) {
|
|||
return (
|
||||
<AppLayout>
|
||||
<Head title="Edit Jabatan" />
|
||||
|
||||
|
||||
<div className="p-4 md:p-8 w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Jabatan</h2>
|
||||
<p className="text-muted-foreground">Perbarui nama jabatan.</p>
|
||||
<p className="text-muted-foreground">Perbarui nama dan gaji pokok jabatan.</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/positions">Kembali</Link>
|
||||
|
|
@ -48,19 +50,42 @@ export default function Edit({ position }: PageProps) {
|
|||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
|
||||
<div className="max-w-xl space-y-2">
|
||||
<Label>Nama Jabatan</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
|
||||
|
||||
<div className="max-w-xl space-y-4">
|
||||
{/* Nama Jabatan */}
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Jabatan</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-xs">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gaji Pokok */}
|
||||
<div className="space-y-2">
|
||||
<Label>Gaji Pokok (Rp)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Contoh: 5000000"
|
||||
value={data.basic_salary}
|
||||
onChange={e => setData('basic_salary', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Perubahan ini TIDAK mempengaruhi data payroll yang sudah ada.
|
||||
</p>
|
||||
{errors.basic_salary && (
|
||||
<p className="text-red-500 text-xs">{errors.basic_salary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start gap-4 pt-4">
|
||||
<div className="flex justify-start gap-4 pt-2">
|
||||
<Button type="submit" disabled={processing}>
|
||||
Simpan Perubahan
|
||||
{processing ? 'Menyimpan...' : 'Simpan Perubahan'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href="/admin/positions">Batal</Link>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import { Button } from '@/components/ui/button';
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Input } from '@/components/ui/input';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { router } from '@inertiajs/react';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
interface Position {
|
||||
id: number;
|
||||
|
|
@ -15,51 +21,72 @@ interface Position {
|
|||
|
||||
interface PageProps {
|
||||
positions: Position[];
|
||||
filters: { search?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SharedData {
|
||||
flash: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
export default function Index({ positions, filters }: PageProps) {
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
export default function Index({ positions }: PageProps) {
|
||||
const { flash } = usePage<any>().props as SharedData;
|
||||
useEffect(() => {
|
||||
if (debouncedSearch !== filters.search) {
|
||||
router.get(
|
||||
'/admin/positions',
|
||||
{ search: debouncedSearch },
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const exportExcel = () => {
|
||||
const rows = positions.map((p, i) => ({
|
||||
No: i + 1,
|
||||
'Nama Jabatan': p.name,
|
||||
'Total Karyawan': p.employees_count,
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Jabatan');
|
||||
XLSX.writeFile(wb, `Data_Jabatan_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Manajemen Jabatan" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
{flash?.error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
|
||||
{flash.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Jabatan</h2>
|
||||
<p className="text-muted-foreground">Kelola level dan posisi pekerjaan.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/positions/create">+ Tambah Jabatan</Link>
|
||||
</Button>
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Data Jabatan</h2>
|
||||
<p className="text-muted-foreground">Kelola level dan posisi pekerjaan.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Jabatan</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin/positions/create">+ Tambah Jabatan</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
|
||||
<div className="p-4 w-full md:w-1/3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Cari jabatan..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
|
|
|
|||
|
|
@ -1,42 +1,59 @@
|
|||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: 'Manajemen Pengguna', href: '/admin/users' },
|
||||
{ title: 'Tambah User', href: '/admin/users/create' },
|
||||
];
|
||||
|
||||
export default function Create() {
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
name: string;
|
||||
nip: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function Create({ employees }: PageProps) {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
name: '',
|
||||
employee_id: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
role: '',
|
||||
});
|
||||
|
||||
const selectedEmployee = employees.find((e) => String(e.id) === data.employee_id) ?? null;
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post('/admin/users');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Tambah User Baru" />
|
||||
<AppLayout>
|
||||
<Head title="Buat Akun User" />
|
||||
|
||||
<div className="p-4 md:p-8 w-full">
|
||||
<div className="p-4 md:p-8 w-full mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Tambah User Baru</h2>
|
||||
<p className="text-muted-foreground">Buat akun pengguna baru beserta hak aksesnya.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Buat Akun User</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Hubungkan akun login dengan data karyawan yang sudah terdaftar.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/users">Kembali</Link>
|
||||
|
|
@ -45,114 +62,104 @@ export default function Create() {
|
|||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form User</CardTitle>
|
||||
<CardTitle>Form Akun Baru</CardTitle>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Nama */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Lengkap <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Contoh: Budi Santoso"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-xs">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Contoh: budi@hris.com"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-xs">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Minimal 8 karakter"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-xs">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Konfirmasi Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Konfirmasi Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
placeholder="Ulangi password di atas"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.password_confirmation && (
|
||||
<p className="text-red-500 text-xs">{errors.password_confirmation}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">
|
||||
Role / Hak Akses <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={data.role}
|
||||
onValueChange={(val) => setData('role', val)}
|
||||
>
|
||||
<SelectTrigger id="role" className="w-full">
|
||||
<SelectValue placeholder="Pilih role..." />
|
||||
{/* Pilih Karyawan */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Karyawan <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{employees.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
Semua karyawan sudah memiliki akun.
|
||||
</div>
|
||||
) : (
|
||||
<Select onValueChange={(val) => setData('employee_id', val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih karyawan..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={String(emp.id)}>
|
||||
<span className="font-medium">{emp.name}</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
({emp.nip} — {emp.position})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.role && (
|
||||
<p className="text-red-500 text-xs">{errors.role}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{errors.employee_id && (
|
||||
<p className="text-xs text-red-500">{errors.employee_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
{/* Info karyawan terpilih */}
|
||||
{selectedEmployee && (
|
||||
<div className="rounded-md bg-muted/40 border px-4 py-3 text-sm">
|
||||
<p className="text-muted-foreground text-xs mb-1">Nama akun akan dibuat sebagai:</p>
|
||||
<p className="font-semibold">{selectedEmployee.name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="nama@perusahaan.com"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Role <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select onValueChange={(val) => setData('role', val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pilih role..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.role && (
|
||||
<p className="text-xs text-red-500">{errors.role}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper text password default */}
|
||||
<p className="text-xs text-muted-foreground border border-dashed rounded-md px-3 py-2">
|
||||
*password default user adalah <span className="font-semibold text-foreground">'password'</span>
|
||||
</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-start gap-3 pt-1">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || !data.employee_id}
|
||||
>
|
||||
{processing ? 'Menyimpan...' : 'Buat Akun'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href="/admin/users">Batal</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan User'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ import React, { useState } from 'react';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
|
|
@ -18,6 +23,7 @@ interface User {
|
|||
|
||||
interface PageProps {
|
||||
users: User[];
|
||||
filters: { search?: string; role?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -25,10 +31,35 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
{ title: 'Manajemen Pengguna', href: '/admin/users' },
|
||||
];
|
||||
|
||||
export default function Index({ users }: PageProps) {
|
||||
const { flash } = usePage<any>().props;
|
||||
export default function Index({ users, filters }: PageProps) {
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [roleFilter, setRoleFilter] = useState(filters.role || 'all');
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
const [processingId, setProcessingId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearch !== filters.search || (roleFilter !== 'all' && roleFilter !== filters.role)) {
|
||||
router.get(
|
||||
'/admin/users',
|
||||
{ search: debouncedSearch, role: roleFilter === 'all' ? '' : roleFilter },
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
}
|
||||
}, [debouncedSearch, roleFilter]);
|
||||
|
||||
const exportExcel = () => {
|
||||
const rows = users.map((u, i) => ({
|
||||
No: i + 1,
|
||||
Nama: u.name,
|
||||
Email: u.email,
|
||||
Role: u.role.toUpperCase(),
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Pengguna');
|
||||
XLSX.writeFile(wb, `Data_Pengguna_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
|
||||
const updateRole = (id: number, role: string) => {
|
||||
setProcessingId(id);
|
||||
router.patch(
|
||||
|
|
@ -47,35 +78,48 @@ export default function Index({ users }: PageProps) {
|
|||
|
||||
<div className="p-4 md:p-8 w-full space-y-4">
|
||||
|
||||
{/* Flash Messages */}
|
||||
{flash?.success && (
|
||||
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
{flash?.error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
|
||||
{flash.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Akses User</h2>
|
||||
<p className="text-muted-foreground">Kelola akun dan hak akses pengguna sistem.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/users/create">+ Tambah User Baru</Link>
|
||||
</Button>
|
||||
{/* Page Header */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Akses User</h2>
|
||||
<p className="text-muted-foreground">Kelola akun dan hak akses pengguna sistem.</p>
|
||||
</div>
|
||||
|
||||
{/* Tabel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle>Daftar Pengguna</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={exportExcel}>Export Excel</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin/users/create">+ Tambah User Baru</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
|
||||
<div className="p-4 flex flex-col md:flex-row gap-4">
|
||||
<div className="w-full md:w-1/3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Cari pengguna..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-1/4">
|
||||
<Select value={roleFilter} onValueChange={(val) => setRoleFilter(val)}>
|
||||
<SelectTrigger><SelectValue placeholder="Filter Role" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Role</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
|
|
|
|||
|
|
@ -1,120 +1,141 @@
|
|||
import { Form, Head } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { register } from '@/routes';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Props = {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
canRegister: boolean;
|
||||
};
|
||||
|
||||
export default function Login({
|
||||
status,
|
||||
canResetPassword,
|
||||
canRegister,
|
||||
}: Props) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Log in to your account"
|
||||
description="Enter your email and password below to log in"
|
||||
>
|
||||
<Head title="Log in" />
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password']}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
export default function Login({ status, canResetPassword }: Props) {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false as boolean,
|
||||
});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
// Layar penuh, tengah, background abu muda
|
||||
<div className="min-h-screen bg-zinc-50 flex items-center justify-center px-4">
|
||||
<Head title="Login — HRIS" />
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
{/* Logo / Nama Aplikasi */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center">
|
||||
<img src={"/assets/logo.jpg"} alt="Logo" className="h-full w-full object-contain p-1" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">HRIS APP</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">PT. ZHANHUI JAYA INDONESIA</p>
|
||||
</div>
|
||||
|
||||
{/* Card Login */}
|
||||
<Card className="border shadow-sm">
|
||||
<CardHeader className="pb-0 pt-6 px-6">
|
||||
{/* Status (misal: password reset berhasil) */}
|
||||
{status && (
|
||||
<div className="mb-2 rounded-md bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-6 pb-6 pt-4">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="nama@perusahaan.com"
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
placeholder="email@example.com"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink
|
||||
href={request()}
|
||||
className="ml-auto text-sm"
|
||||
tabIndex={5}
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</TextLink>
|
||||
Lupa password?
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
{errors.password && (
|
||||
<p className="text-xs text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Remember Me */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
tabIndex={3}
|
||||
checked={data.remember}
|
||||
onCheckedChange={(checked) =>
|
||||
setData('remember', checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
<Label
|
||||
htmlFor="remember"
|
||||
className="text-sm font-normal text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Ingat saya
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Tombol Login */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
tabIndex={4}
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
data-test="login-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Log in
|
||||
{processing ? 'Memproses...' : 'Masuk'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canRegister && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
Sign up
|
||||
</TextLink>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} HRIS App. All rights reserved.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,376 @@
|
|||
import { Head, useForm, usePage } from '@inertiajs/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import EmployeeLayout from '@/layouts/employee-layout';
|
||||
import { AttendanceMapModal } from '@/components/AttendanceMapModal';
|
||||
import { MapPin, AlertCircle, RefreshCw, CheckCircle2, LogOut } from 'lucide-react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface Attendance {
|
||||
id: number;
|
||||
date: string;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
status: 'present' | 'leave' | 'dispensation';
|
||||
notes: string | null;
|
||||
latitude_in?: string | null;
|
||||
longitude_in?: string | null;
|
||||
latitude_out?: string | null;
|
||||
longitude_out?: string | null;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
attendances: Attendance[];
|
||||
todayAttendance: Attendance | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function AttendanceIndex({ attendances, todayAttendance }: PageProps) {
|
||||
const { flash } = usePage<any>().props;
|
||||
const [locationError, setLocationError] = useState<string | null>(null);
|
||||
const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [isLoadingLocation, setIsLoadingLocation] = useState<boolean>(true);
|
||||
|
||||
const { data: regulerData, setData: setRegulerData, post: postReguler, processing: processingReguler } = useForm({
|
||||
latitude_in: '',
|
||||
longitude_in: '',
|
||||
latitude_out: '',
|
||||
longitude_out: '',
|
||||
});
|
||||
|
||||
const { data: leaveData, setData: setLeaveData, post: postLeave, processing: processingLeave, reset: resetLeave, errors: leaveErrors } = useForm({
|
||||
date: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const { data: dispenData, setData: setDispenData, post: postDispen, processing: processingDispen, reset: resetDispen, errors: dispenErrors } = useForm({
|
||||
latitude_in: '',
|
||||
longitude_in: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const getLocation = () => {
|
||||
setIsLoadingLocation(true);
|
||||
setLocationError(null);
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
setLocationError('Geolocation tidak didukung oleh browser ini.');
|
||||
setIsLoadingLocation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lng = position.coords.longitude;
|
||||
setCoordinates({ lat, lng });
|
||||
setRegulerData({
|
||||
...regulerData,
|
||||
latitude_in: lat.toString(),
|
||||
longitude_in: lng.toString(),
|
||||
latitude_out: lat.toString(),
|
||||
longitude_out: lng.toString(),
|
||||
});
|
||||
setDispenData({
|
||||
...dispenData,
|
||||
latitude_in: lat.toString(),
|
||||
longitude_in: lng.toString(),
|
||||
});
|
||||
setIsLoadingLocation(false);
|
||||
},
|
||||
(error) => {
|
||||
let errorMsg = 'Gagal mengambil lokasi.';
|
||||
if (error.code === 1) errorMsg = 'Izin lokasi ditolak. Harap izinkan akses lokasi di pengaturan browser/perangkat Anda.';
|
||||
setLocationError(errorMsg);
|
||||
setIsLoadingLocation(false);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getLocation();
|
||||
}, []);
|
||||
|
||||
const handleClockIn = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
postReguler('/employee/attendances/clock-in');
|
||||
};
|
||||
|
||||
const handleClockOut = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
postReguler('/employee/attendances/clock-out');
|
||||
};
|
||||
|
||||
const handleLeaveSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
postLeave('/employee/attendances/leave', {
|
||||
onSuccess: () => resetLeave()
|
||||
});
|
||||
};
|
||||
|
||||
const handleDispenSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
postDispen('/employee/attendances/dispensation', {
|
||||
onSuccess: () => resetDispen()
|
||||
});
|
||||
};
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'present': return 'Hadir';
|
||||
case 'leave': return 'Izin/Cuti';
|
||||
case 'dispensation': return 'Dispensasi';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const isClockInDisabled = processingReguler || isLoadingLocation || !coordinates || Boolean(todayAttendance?.check_in);
|
||||
const isClockOutDisabled = processingReguler || isLoadingLocation || !coordinates || !todayAttendance || Boolean(todayAttendance?.check_out);
|
||||
|
||||
return (
|
||||
<EmployeeLayout title="Absensi Karyawan">
|
||||
<div className="bg-gradient-to-r from-sky-400 to-blue-500 text-white md:rounded-b-3xl shadow-sm relative mb-8 md:mb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-20 md:pb-24">
|
||||
<div className="flex flex-col mb-2">
|
||||
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">Portal Absensi</h1>
|
||||
<p className="text-sky-100 text-sm md:text-base opacity-90 mt-2">Lakukan absen masuk, pulang, atau ajukan izin.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 md:-mt-24 relative z-10 pb-12 space-y-6 md:space-y-8">
|
||||
|
||||
<div className={`p-4 md:p-5 rounded-xl md:rounded-2xl text-sm shadow-sm ${
|
||||
isLoadingLocation ? 'bg-sky-100 text-sky-800' :
|
||||
locationError ? 'bg-red-100 text-red-800' :
|
||||
'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
<div className="flex items-start md:items-center gap-3">
|
||||
{isLoadingLocation ? (
|
||||
<RefreshCw className="w-5 h-5 md:w-6 md:h-6 animate-spin shrink-0 mt-0.5 md:mt-0" />
|
||||
) : locationError ? (
|
||||
<AlertCircle className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
|
||||
) : (
|
||||
<MapPin className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
|
||||
)}
|
||||
<div className="flex-1 md:flex md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold mb-0.5 md:text-base">
|
||||
{isLoadingLocation ? 'Mencari lokasi Anda...' :
|
||||
locationError ? 'Gagal Akses Lokasi' :
|
||||
'Lokasi Ditemukan'}
|
||||
</p>
|
||||
<p className="text-xs md:text-sm opacity-90">
|
||||
{isLoadingLocation ? 'Harap tunggu, pastikan GPS aktif.' :
|
||||
locationError ? locationError :
|
||||
`Koordinat: ${coordinates?.lat.toFixed(6)}, ${coordinates?.lng.toFixed(6)}`}
|
||||
</p>
|
||||
</div>
|
||||
{locationError && (
|
||||
<Button onClick={getLocation} variant="outline" size="sm" className="mt-2 md:mt-0 h-8 md:h-9 bg-white">
|
||||
Coba Lagi
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peta Preview - Hanya muncul jika koordinat berhasil didapatkan */}
|
||||
{coordinates && !isLoadingLocation && !locationError && (
|
||||
<div className="mt-4 h-48 md:h-64 w-full rounded-md overflow-hidden border-2 border-green-200 shadow-sm">
|
||||
<MapContainer
|
||||
center={[coordinates.lat, coordinates.lng]}
|
||||
zoom={16}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom={false}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={[coordinates.lat, coordinates.lng]}>
|
||||
<Popup>
|
||||
<div className="text-center">
|
||||
<p className="font-semibold text-sm">📍 Lokasi Anda Saat Ini</p>
|
||||
<p className="text-xs text-slate-600 mt-1">
|
||||
Lat: {coordinates.lat.toFixed(6)}<br />
|
||||
Lng: {coordinates.lng.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="border-slate-100 shadow-md h-full">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<Tabs defaultValue="reguler" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-3 mb-6 bg-slate-100/50">
|
||||
<TabsTrigger value="reguler">Harian</TabsTrigger>
|
||||
<TabsTrigger value="izin">Izin</TabsTrigger>
|
||||
<TabsTrigger value="dispen">Dispen</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="reguler" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-sm md:text-base font-semibold text-slate-800">Absen Harian</h3>
|
||||
<p className="text-xs md:text-sm text-slate-500 mt-1">
|
||||
{todayAttendance
|
||||
? 'Anda sudah memiliki catatan absensi hari ini.'
|
||||
: 'Sistem membutuhkan akses lokasi.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleClockIn}
|
||||
disabled={isClockInDisabled}
|
||||
className="w-full bg-sky-600 hover:bg-sky-700 h-14 md:h-16 flex flex-col gap-1 items-center justify-center rounded-xl transition-all"
|
||||
>
|
||||
<CheckCircle2 className="w-5 h-5 md:w-6 md:h-6" />
|
||||
<span className="text-xs md:text-sm font-semibold">Clock In</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClockOut}
|
||||
disabled={isClockOutDisabled}
|
||||
variant="outline"
|
||||
className="w-full h-14 md:h-16 flex flex-col gap-1 items-center justify-center rounded-xl border-slate-200 transition-all"
|
||||
>
|
||||
<LogOut className="w-5 h-5 md:w-6 md:h-6 text-slate-500" />
|
||||
<span className="text-xs md:text-sm font-semibold text-slate-700">Clock Out</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="izin" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
|
||||
<form onSubmit={handleLeaveSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date" className="text-xs md:text-sm">Tanggal Izin</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
required
|
||||
value={leaveData.date}
|
||||
onChange={e => setLeaveData('date', e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
{leaveErrors.date && <p className="text-xs text-red-500">{leaveErrors.date}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave_notes" className="text-xs md:text-sm">Keterangan (Sakit/Cuti)</Label>
|
||||
<Textarea
|
||||
id="leave_notes"
|
||||
placeholder="Tulis alasan izin..."
|
||||
required
|
||||
value={leaveData.notes}
|
||||
onChange={e => setLeaveData('notes', e.target.value)}
|
||||
className="resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
{leaveErrors.notes && <p className="text-xs text-red-500">{leaveErrors.notes}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={processingLeave} className="w-full h-11 rounded-xl bg-sky-600 hover:bg-sky-700">
|
||||
{processingLeave ? 'Menyimpan...' : 'Submit Izin'}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dispen" className="space-y-4 focus-visible:outline-none focus-visible:ring-0">
|
||||
<form onSubmit={handleDispenSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dispen_notes" className="text-xs md:text-sm">Tujuan / Keterangan</Label>
|
||||
<Textarea
|
||||
id="dispen_notes"
|
||||
placeholder="Contoh: Meeting dengan klien X di lokasi Y"
|
||||
required
|
||||
value={dispenData.notes}
|
||||
onChange={e => setDispenData('notes', e.target.value)}
|
||||
className="resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
{dispenErrors.notes && <p className="text-xs text-red-500">{dispenErrors.notes}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={processingDispen || !coordinates} className="w-full h-11 rounded-xl bg-sky-600 hover:bg-sky-700">
|
||||
{processingDispen ? 'Menyimpan...' : 'Submit Dispensasi'}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="border-slate-100 shadow-md h-full overflow-hidden">
|
||||
<CardHeader className="pb-4 border-b border-slate-100 bg-white">
|
||||
<CardTitle className="text-lg">Riwayat Absensi</CardTitle>
|
||||
<CardDescription>Data kehadiran Anda terbaru</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{attendances.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||
<TableHead className="whitespace-nowrap">Tanggal</TableHead>
|
||||
<TableHead className="whitespace-nowrap">Clock In</TableHead>
|
||||
<TableHead className="whitespace-nowrap">Clock Out</TableHead>
|
||||
<TableHead className="whitespace-nowrap">Status</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[150px]">Keterangan</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-right">Aksi</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{attendances.map((att) => (
|
||||
<TableRow key={att.id}>
|
||||
<TableCell className="font-medium whitespace-nowrap">{att.date}</TableCell>
|
||||
<TableCell className="font-mono text-slate-600">{att.check_in || '--:--:--'}</TableCell>
|
||||
<TableCell className="font-mono text-slate-600">{att.check_out || '--:--:--'}</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-block px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider bg-slate-100 text-slate-600 rounded-md">
|
||||
{formatStatus(att.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-500 text-xs">
|
||||
{att.notes || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<AttendanceMapModal
|
||||
latitudeIn={att.latitude_in}
|
||||
longitudeIn={att.longitude_in}
|
||||
latitudeOut={att.latitude_out}
|
||||
longitudeOut={att.longitude_out}
|
||||
employeeName="Anda"
|
||||
date={att.date}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-10 text-center">
|
||||
<p className="text-sm text-slate-500">Belum ada riwayat absensi.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</EmployeeLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +1,116 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { User2, Mail, ShieldCheck } from 'lucide-react';
|
||||
import type { SharedData } from '@/types';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import EmployeeLayout from '@/layouts/employee-layout';
|
||||
import { CheckCircle2, Clock, CalendarDays, ClipboardList, Briefcase, CalendarOff } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: 'Portal Saya', href: '/employee/index' },
|
||||
];
|
||||
interface Stats {
|
||||
present: number;
|
||||
leave: number;
|
||||
dispensation: number;
|
||||
on_time: number;
|
||||
late: number;
|
||||
}
|
||||
|
||||
export default function EmployeeIndex() {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
interface PageProps {
|
||||
stats: Stats | null;
|
||||
employee: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default function EmployeeIndex({ stats, employee }: PageProps) {
|
||||
const { auth } = usePage<any>().props;
|
||||
const user = auth.user;
|
||||
|
||||
const statData = stats || {
|
||||
present: 0,
|
||||
leave: 0,
|
||||
dispensation: 0,
|
||||
on_time: 0,
|
||||
late: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Portal Karyawan" />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-8">
|
||||
{/* Header Sambutan */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Selamat datang, {user.name}!
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ini adalah portal SDM Anda. Pantau informasi akun Anda di sini.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Kartu Info Akun */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Nama Lengkap</CardTitle>
|
||||
<User2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">Nama akun terdaftar</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Alamat Email</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-semibold truncate">{user.email}</p>
|
||||
<p className="text-xs text-muted-foreground">Email untuk login</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Hak Akses</CardTitle>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Badge variant="secondary" className="w-fit capitalize">
|
||||
{user.role}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">Role akun Anda saat ini</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmployeeLayout title="Portal Karyawan">
|
||||
<div className="bg-gradient-to-r from-sky-400 to-blue-500 text-white md:rounded-b-3xl shadow-sm relative mb-8 md:mb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-20 md:pb-24">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">Halo, {user.name.split(' ')[0]}!</h1>
|
||||
<p className="text-sky-100 text-sm md:text-base opacity-90 mt-2">Semoga harimu menyenangkan dan produktif.</p>
|
||||
</div>
|
||||
<div className="hidden md:flex w-16 h-16 bg-white/20 backdrop-blur-md rounded-2xl items-center justify-center border border-white/30 shadow-inner">
|
||||
<img src="/assets/logo-clear.png" alt="Logo" className="w-10 h-10 object-contain drop-shadow-md" onError={(e) => { e.currentTarget.style.display='none'; }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 md:-mt-24 relative z-10 pb-12">
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 p-5 md:p-8 border border-slate-100 grid grid-cols-1 sm:grid-cols-3 gap-4 md:gap-6 mb-8">
|
||||
<div className="flex flex-col items-center justify-center p-4 md:p-6 bg-green-100 rounded-xl hover:shadow-md transition-shadow">
|
||||
<CheckCircle2 className="w-8 h-8 md:w-10 md:h-10 text-green-600 mb-3" />
|
||||
<span className="text-2xl md:text-3xl font-bold text-green-700">{statData.on_time}</span>
|
||||
<span className="text-xs md:text-sm font-semibold text-green-700 uppercase tracking-wider mt-2 text-center">Tepat Waktu</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center p-4 md:p-6 bg-orange-100 rounded-xl hover:shadow-md transition-shadow">
|
||||
<Clock className="w-8 h-8 md:w-10 md:h-10 text-orange-600 mb-3" />
|
||||
<span className="text-2xl md:text-3xl font-bold text-orange-700">{statData.leave}</span>
|
||||
<span className="text-xs md:text-sm font-semibold text-orange-700 uppercase tracking-wider mt-2 text-center">Toleransi/Izin</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center p-4 md:p-6 bg-red-100 rounded-xl hover:shadow-md transition-shadow">
|
||||
<CalendarDays className="w-8 h-8 md:w-10 md:h-10 text-red-600 mb-3" />
|
||||
<span className="text-2xl md:text-3xl font-bold text-red-700">{statData.late}</span>
|
||||
<span className="text-xs md:text-sm font-semibold text-red-700 uppercase tracking-wider mt-2 text-center">Terlambat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-base md:text-lg font-bold text-slate-800 tracking-tight mb-4">Statistik Bulan Ini</h2>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 divide-y divide-slate-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-5 hover:bg-slate-50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-sky-50 flex items-center justify-center text-sky-600">
|
||||
<ClipboardList className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700 md:text-base text-sm">Jumlah Presensi</p>
|
||||
<p className="text-xs md:text-sm text-slate-500">Total kehadiran tercatat</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl font-bold text-sky-600 px-4">{statData.present}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-5 hover:bg-slate-50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center text-purple-600">
|
||||
<Briefcase className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700 md:text-base text-sm">Jumlah Kegiatan</p>
|
||||
<p className="text-xs md:text-sm text-slate-500">Tugas luar / dispensasi</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl font-bold text-purple-600 px-4">{statData.dispensation}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-5 hover:bg-slate-50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-rose-50 flex items-center justify-center text-rose-600">
|
||||
<CalendarOff className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700 md:text-base text-sm">Jumlah Cuti/Izin</p>
|
||||
<p className="text-xs md:text-sm text-slate-500">Absen disetujui</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl font-bold text-rose-600 px-4">{statData.leave}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</EmployeeLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Transition } from '@headlessui/react';
|
||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import DeleteUser from '@/components/delete-user';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -143,7 +142,7 @@ export default function Profile({
|
|||
</Form>
|
||||
</div>
|
||||
|
||||
<DeleteUser />
|
||||
{/* Delete account section hidden intentionally */}
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,9 +32,8 @@
|
|||
|
||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="icon" href="/assets/logo-clear.png" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/assets/logo-clear.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
|
||||
use App\Http\Controllers\Admin\DepartmentController;
|
||||
use App\Http\Controllers\Admin\EmployeeController;
|
||||
use App\Http\Controllers\Admin\AttendanceController;
|
||||
use App\Http\Controllers\Admin\PayrollController;
|
||||
use App\Http\Controllers\Admin\PositionController;
|
||||
use App\Http\Controllers\Admin\UserController;
|
||||
use App\Http\Controllers\Admin\UserController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Redirect root ke halaman login
|
||||
Route::get('/', function () {
|
||||
return Inertia::render('welcome', [
|
||||
'canRegister' => Features::enabled(Features::registration()),
|
||||
]);
|
||||
return redirect('/login');
|
||||
})->name('home');
|
||||
|
||||
Route::get('dashboard', [DashboardController::class, 'index'])
|
||||
|
|
@ -24,6 +24,10 @@
|
|||
Route::resource('departments', DepartmentController::class);
|
||||
Route::resource('positions', PositionController::class);
|
||||
Route::resource('employees', EmployeeController::class);
|
||||
Route::resource('attendance', AttendanceController::class)->only(['index', 'create', 'store']);
|
||||
Route::resource('payrolls', PayrollController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']);
|
||||
Route::patch('payrolls/{payroll}/status', [PayrollController::class, 'updateStatus'])->name('payrolls.updateStatus');
|
||||
Route::get('attendances', [\App\Http\Controllers\Admin\AttendanceController::class, 'index'])->name('attendances.index');
|
||||
|
||||
Route::get('users', [UserController::class, 'index'])->name('users.index');
|
||||
Route::get('users/create', [UserController::class, 'create'])->name('users.create');
|
||||
|
|
@ -33,9 +37,13 @@
|
|||
|
||||
// KELOMPOK KARYAWAN
|
||||
Route::middleware(['auth', 'verified', 'role:employee'])->group(function () {
|
||||
Route::get('/employee/index', function () {
|
||||
return Inertia::render('employee/index');
|
||||
})->name('employee.index');
|
||||
Route::get('/employee/index', [\App\Http\Controllers\Employee\EmployeeDashboardController::class, 'index'])->name('employee.index');
|
||||
|
||||
Route::get('/employee/attendances', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'index'])->name('employee.attendances.index');
|
||||
Route::post('/employee/attendances/clock-in', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'clockIn'])->name('employee.attendances.clock-in');
|
||||
Route::post('/employee/attendances/clock-out', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'clockOut'])->name('employee.attendances.clock-out');
|
||||
Route::post('/employee/attendances/leave', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'submitLeave'])->name('employee.attendances.leave');
|
||||
Route::post('/employee/attendances/dispensation', [\App\Http\Controllers\Employee\EmployeeAttendanceController::class, 'submitDispensation'])->name('employee.attendances.dispensation');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
|
@ -5,6 +5,9 @@ import laravel from 'laravel-vite-plugin';
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.tsx'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue