diff --git a/app/Http/Controllers/Admin/AttendanceController.php b/app/Http/Controllers/Admin/AttendanceController.php new file mode 100644 index 0000000..60f8e74 --- /dev/null +++ b/app/Http/Controllers/Admin/AttendanceController.php @@ -0,0 +1,88 @@ +filled('date')) { + $query->whereDate('date', $request->date); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('department_id')) { + $query->whereHas('employee', function ($q) use ($request) { + $q->where('department_id', $request->department_id); + }); + } + + if ($request->filled('search')) { + $query->whereHas('employee', function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('nip', 'like', '%' . $request->search . '%'); + }); + } + + return Inertia::render('admin/attendance/index', [ + 'attendances' => $query->latest('date')->paginate(15)->withQueryString(), + 'departments' => Department::select('id', 'name')->orderBy('name')->get(), + 'filters' => $request->only(['search', 'date', 'status', 'department_id']), + ]); + } + + public function create() + { + $employees = Employee::with('department') + ->orderBy('name') + ->get() + ->map(fn($employee) => [ + 'id' => $employee->id, + 'name' => $employee->name, + 'nip' => $employee->nip, + 'department' => $employee->department?->name, + ]); + + return Inertia::render('admin/attendance/create', [ + 'employees' => $employees, + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'employee_id' => [ + 'required', + 'exists:employees,id', + Rule::unique('attendances')->where(fn($query) => $query->where('date', $request->date)), + ], + 'date' => 'required|date', + 'shift' => 'required|in:Pagi,Siang,Malam', + 'status' => 'required|in:hadir,izin,sakit,alpha', + 'check_in' => 'nullable|date', + 'check_out' => 'nullable|date|after_or_equal:check_in', + 'notes' => 'nullable|string|max:255', + ], [ + 'employee_id.unique' => 'Absensi karyawan pada tanggal ini sudah tercatat.', + 'check_out.after_or_equal' => 'Jam pulang harus setelah jam masuk.', + ]); + + Attendance::create($validated); + + return redirect()->route('admin.attendance.index') + ->with('success', 'Absensi berhasil disimpan.'); + } +} diff --git a/app/Http/Controllers/Admin/EmployeeController.php b/app/Http/Controllers/Admin/EmployeeController.php index dce2275..715caaa 100644 --- a/app/Http/Controllers/Admin/EmployeeController.php +++ b/app/Http/Controllers/Admin/EmployeeController.php @@ -49,6 +49,7 @@ public function store(Request $request) $validated = $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email|unique:users,email', + 'password' => 'required|string|min:8|confirmed', 'nip' => 'required|string|unique:employees,nip', 'gender' => 'required|in:L,P', 'place_of_birth' => 'nullable|string|max:255', @@ -64,7 +65,7 @@ public function store(Request $request) $user = User::create([ 'name' => $validated['name'], 'email' => $validated['email'], - 'password' => Hash::make('password123'), + 'password' => Hash::make($validated['password']), ]); Employee::create([ diff --git a/app/Http/Controllers/Admin/PayrollController.php b/app/Http/Controllers/Admin/PayrollController.php index 647fe8b..5a31694 100644 --- a/app/Http/Controllers/Admin/PayrollController.php +++ b/app/Http/Controllers/Admin/PayrollController.php @@ -6,6 +6,7 @@ use App\Models\Employee; use App\Models\Payroll; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Inertia\Inertia; class PayrollController extends Controller @@ -58,12 +59,19 @@ public function store(Request $request) { $validated = $request->validate([ 'employee_id' => 'required|exists:employees,id', - 'period' => 'required|string|max:7', + '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', + ], [ + 'period.date_format' => 'Format periode harus YYYY-MM.', + 'period.unique' => 'Payroll karyawan untuk periode ini sudah ada.', ]); // net_salary = basic_salary + Σbonus − Σpotongan, minimal 0 diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 097176e..2b5295c 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -48,6 +48,7 @@ public function store(Request $request) 'unique:users,employee_id', ], 'email' => 'required|email|max:255|unique:users,email', + 'password' => 'required|string|min:8|confirmed', 'role' => ['required', Rule::in(['admin', 'employee'])], ], [ 'employee_id.required' => 'Karyawan wajib dipilih.', @@ -56,6 +57,9 @@ public function store(Request $request) 'email.required' => 'Email wajib diisi.', 'email.email' => 'Format email tidak valid.', 'email.unique' => 'Email sudah terdaftar.', + 'password.required' => 'Password wajib diisi.', + 'password.min' => 'Password minimal 8 karakter.', + 'password.confirmed' => 'Konfirmasi password tidak cocok.', 'role.required' => 'Role wajib dipilih.', 'role.in' => 'Role hanya boleh admin atau employee.', ]); @@ -65,13 +69,13 @@ public function store(Request $request) User::create([ 'name' => $employee->name, 'email' => $validated['email'], - 'password' => Hash::make('password'), + 'password' => Hash::make($validated['password']), 'role' => $validated['role'], 'employee_id' => $employee->id, ]); return redirect()->route('admin.users.index') - ->with('success', "Akun untuk {$employee->name} berhasil dibuat. Password default: password"); + ->with('success', "Akun untuk {$employee->name} berhasil dibuat."); } public function update(Request $request, User $user) diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php new file mode 100644 index 0000000..cd58081 --- /dev/null +++ b/app/Models/Attendance.php @@ -0,0 +1,32 @@ + 'date', + 'check_in' => 'datetime', + 'check_out' => 'datetime', + ]; + + public function employee() + { + return $this->belongsTo(Employee::class); + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php index 17b5bef..7e38cb0 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -41,4 +41,9 @@ public function position() { return $this->belongsTo(Position::class); } + + public function attendances() + { + return $this->hasMany(Attendance::class); + } } \ No newline at end of file diff --git a/database/migrations/2026_02_14_120000_create_departments_table.php b/database/migrations/2026_02_14_120000_create_departments_table.php new file mode 100644 index 0000000..66895e3 --- /dev/null +++ b/database/migrations/2026_02_14_120000_create_departments_table.php @@ -0,0 +1,22 @@ +unique(['employee_id', 'period'], 'payrolls_employee_period_unique'); + }); + } + + public function down(): void + { + Schema::table('payrolls', function (Blueprint $table) { + $table->dropUnique('payrolls_employee_period_unique'); + }); + } +}; diff --git a/database/migrations/2026_04_20_110000_create_attendances_table.php b/database/migrations/2026_04_20_110000_create_attendances_table.php new file mode 100644 index 0000000..8cfba4c --- /dev/null +++ b/database/migrations/2026_04_20_110000_create_attendances_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('employee_id')->constrained()->onDelete('cascade'); + $table->date('date'); + $table->enum('shift', ['Pagi', 'Siang', 'Malam']); + $table->dateTime('check_in')->nullable(); + $table->dateTime('check_out')->nullable(); + $table->enum('status', ['hadir', 'izin', 'sakit', 'alpha'])->default('hadir'); + $table->string('notes')->nullable(); + $table->timestamps(); + + $table->unique(['employee_id', 'date'], 'attendances_employee_date_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('attendances'); + } +}; diff --git a/docs/flowchart-aplikasi.drawio b/docs/flowchart-aplikasi.drawio new file mode 100644 index 0000000..9dbcd14 --- /dev/null +++ b/docs/flowchart-aplikasi.drawio @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/flowchart-aplikasi.md b/docs/flowchart-aplikasi.md new file mode 100644 index 0000000..442a1a7 --- /dev/null +++ b/docs/flowchart-aplikasi.md @@ -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. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cb02e25..80e45cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "HRIS_App", + "name": "HR_Management__App", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 3c18048..e156ffe 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Link, usePage } from '@inertiajs/react'; -import { LayoutGrid, Users, Briefcase, Building2, SquareUser, Wallet } from 'lucide-react'; +import { LayoutGrid, Users, Briefcase, Building2, SquareUser, Wallet, CalendarCheck2 } from 'lucide-react'; import { NavMain } from '@/components/nav-main'; import { NavUser } from '@/components/nav-user'; import { @@ -42,6 +42,11 @@ export function AppSidebar() { href: '/admin/positions', icon: Briefcase, }, + { + title: 'Manajemen Absensi', + href: '/admin/attendance', + icon: CalendarCheck2, + }, { title: 'Manajemen Gaji', href: '/admin/payrolls', diff --git a/resources/js/pages/admin/attendance/create.tsx b/resources/js/pages/admin/attendance/create.tsx new file mode 100644 index 0000000..ef0bb73 --- /dev/null +++ b/resources/js/pages/admin/attendance/create.tsx @@ -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 } = 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(); + + post('/admin/attendance', { + transform: (formData) => ({ + ...formData, + check_in: formData.check_in || null, + check_out: formData.check_out || null, + notes: formData.notes || null, + }), + }); + }; + + return ( + + + +
+ + + + + Input Absensi Karyawan +

Isi data absensi harian untuk 1 karyawan per tanggal.

+
+ + + + +
+
+ + + {errors.employee_id &&

{errors.employee_id}

} +
+ + {selectedEmployee && ( +
+ Departemen: + {selectedEmployee.department || '-'} +
+ )} + +
+
+ + setData('date', e.target.value)} + /> + {errors.date &&

{errors.date}

} +
+ +
+ + + {errors.shift &&

{errors.shift}

} +
+
+ +
+
+ + setData('check_in', e.target.value)} + /> + {errors.check_in &&

{errors.check_in}

} +
+ +
+ + setData('check_out', e.target.value)} + /> + {errors.check_out &&

{errors.check_out}

} +
+
+ +
+ + + {errors.status &&

{errors.status}

} +
+ +
+ + setData('notes', e.target.value)} + placeholder="Opsional" + /> + {errors.notes &&

{errors.notes}

} +
+ + + +
+ + +
+ +
+
+
+
+ ); +} diff --git a/resources/js/pages/admin/attendance/index.tsx b/resources/js/pages/admin/attendance/index.tsx new file mode 100644 index 0000000..c6b76b5 --- /dev/null +++ b/resources/js/pages/admin/attendance/index.tsx @@ -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 = { + 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().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 ( + + + +
+
+

Manajemen Absensi

+

Pantau absensi harian karyawan berdasarkan tanggal, status, dan departemen.

+
+ + {flash?.success && ( +
+ {flash.success} +
+ )} + + {flash?.error && ( +
+ {flash.error} +
+ )} + + + + Daftar Absensi + + + + + +
+ setSearch(e.target.value)} + /> + + setDate(e.target.value)} + /> + + + + +
+ + +
+ + + + + + + + + + + + + + {attendances.data.length > 0 ? ( + attendances.data.map((attendance) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
TanggalKaryawanShiftMasukPulangStatusCatatan
{formatDate(attendance.date)} +
{attendance.employee.name}
+
+ NIP: {attendance.employee.nip} | {attendance.employee.department?.name || '-'} +
+
{attendance.shift}{formatTime(attendance.check_in)}{formatTime(attendance.check_out)} + + {attendance.status.toUpperCase()} + + {attendance.notes || '-'}
+ Belum ada data absensi. +
+
+
+
+
+
+ ); +} diff --git a/resources/js/pages/admin/employees/create.tsx b/resources/js/pages/admin/employees/create.tsx index 64e1aa5..541804d 100644 --- a/resources/js/pages/admin/employees/create.tsx +++ b/resources/js/pages/admin/employees/create.tsx @@ -18,6 +18,8 @@ export default function Create({ departments, positions }: PageProps) { const { data, setData, post, processing, errors } = useForm({ name: '', email: '', + password: '', + password_confirmation: '', nip: '', gender: '', birth_date: '', @@ -83,6 +85,25 @@ export default function Create({ departments, positions }: PageProps) { {errors.email &&
{errors.email}
} +
+ + setData('password', e.target.value)} + /> + {errors.password &&
{errors.password}
} +
+ +
+ + setData('password_confirmation', e.target.value)} + /> +
+

Nama akun akan dibuat sebagai:

{selectedEmployee.name}

-

- Password default: password -

)} @@ -126,6 +125,32 @@ export default function Create({ employees }: PageProps) { )} +
+ + setData('password', e.target.value)} + /> + {errors.password && ( +

{errors.password}

+ )} +
+ +
+ + setData('password_confirmation', e.target.value)} + /> +
+ {/* Role */}