feat absensi

This commit is contained in:
IlhamIslamy 2026-04-23 10:25:39 +07:00
parent 859dbe13a1
commit b9ace087ee
19 changed files with 1025 additions and 9 deletions

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\Department;
use App\Models\Employee;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class AttendanceController extends Controller
{
public function index(Request $request)
{
$query = Attendance::with(['employee.department']);
if ($request->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.');
}
}

View File

@ -49,6 +49,7 @@ public function store(Request $request)
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email', 'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8|confirmed',
'nip' => 'required|string|unique:employees,nip', 'nip' => 'required|string|unique:employees,nip',
'gender' => 'required|in:L,P', 'gender' => 'required|in:L,P',
'place_of_birth' => 'nullable|string|max:255', 'place_of_birth' => 'nullable|string|max:255',
@ -64,7 +65,7 @@ public function store(Request $request)
$user = User::create([ $user = User::create([
'name' => $validated['name'], 'name' => $validated['name'],
'email' => $validated['email'], 'email' => $validated['email'],
'password' => Hash::make('password123'), 'password' => Hash::make($validated['password']),
]); ]);
Employee::create([ Employee::create([

View File

@ -6,6 +6,7 @@
use App\Models\Employee; use App\Models\Employee;
use App\Models\Payroll; use App\Models\Payroll;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
class PayrollController extends Controller class PayrollController extends Controller
@ -58,12 +59,19 @@ public function store(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
'employee_id' => 'required|exists:employees,id', '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', 'basic_salary' => 'required|integer|min:0',
'details' => 'nullable|array', 'details' => 'nullable|array',
'details.*.name' => 'required|string|max:100', 'details.*.name' => 'required|string|max:100',
'details.*.type' => 'required|in:bonus,deduction', 'details.*.type' => 'required|in:bonus,deduction',
'details.*.amount' => 'required|integer|min:0', '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 // net_salary = basic_salary + Σbonus Σpotongan, minimal 0

View File

@ -48,6 +48,7 @@ public function store(Request $request)
'unique:users,employee_id', 'unique:users,employee_id',
], ],
'email' => 'required|email|max:255|unique:users,email', 'email' => 'required|email|max:255|unique:users,email',
'password' => 'required|string|min:8|confirmed',
'role' => ['required', Rule::in(['admin', 'employee'])], 'role' => ['required', Rule::in(['admin', 'employee'])],
], [ ], [
'employee_id.required' => 'Karyawan wajib dipilih.', 'employee_id.required' => 'Karyawan wajib dipilih.',
@ -56,6 +57,9 @@ public function store(Request $request)
'email.required' => 'Email wajib diisi.', 'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.', 'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah terdaftar.', '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.required' => 'Role wajib dipilih.',
'role.in' => 'Role hanya boleh admin atau employee.', 'role.in' => 'Role hanya boleh admin atau employee.',
]); ]);
@ -65,13 +69,13 @@ public function store(Request $request)
User::create([ User::create([
'name' => $employee->name, 'name' => $employee->name,
'email' => $validated['email'], 'email' => $validated['email'],
'password' => Hash::make('password'), 'password' => Hash::make($validated['password']),
'role' => $validated['role'], 'role' => $validated['role'],
'employee_id' => $employee->id, 'employee_id' => $employee->id,
]); ]);
return redirect()->route('admin.users.index') 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) public function update(Request $request, User $user)

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

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

View File

@ -41,4 +41,9 @@ public function position()
{ {
return $this->belongsTo(Position::class); return $this->belongsTo(Position::class);
} }
public function attendances()
{
return $this->hasMany(Attendance::class);
}
} }

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Duplicate migration kept as a no-op so existing schema is not recreated.
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Intentionally no-op.
}
};

View File

@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
// Duplicate migration kept as a no-op so existing schema is not recreated.
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Intentionally no-op.
}
};

View File

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

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('attendances', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->enum('shift', ['Pagi', 'Siang', 'Malam']);
$table->dateTime('check_in')->nullable();
$table->dateTime('check_out')->nullable();
$table->enum('status', ['hadir', 'izin', 'sakit', 'alpha'])->default('hadir');
$table->string('notes')->nullable();
$table->timestamps();
$table->unique(['employee_id', 'date'], 'attendances_employee_date_unique');
});
}
public function down(): void
{
Schema::dropIfExists('attendances');
}
};

View File

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

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

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

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{ {
"name": "HRIS_App", "name": "HR_Management__App",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Link, usePage } from '@inertiajs/react'; 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 { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user'; import { NavUser } from '@/components/nav-user';
import { import {
@ -42,6 +42,11 @@ export function AppSidebar() {
href: '/admin/positions', href: '/admin/positions',
icon: Briefcase, icon: Briefcase,
}, },
{
title: 'Manajemen Absensi',
href: '/admin/attendance',
icon: CalendarCheck2,
},
{ {
title: 'Manajemen Gaji', title: 'Manajemen Gaji',
href: '/admin/payrolls', href: '/admin/payrolls',

View File

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

View File

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

View File

@ -18,6 +18,8 @@ export default function Create({ departments, positions }: PageProps) {
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
name: '', name: '',
email: '', email: '',
password: '',
password_confirmation: '',
nip: '', nip: '',
gender: '', gender: '',
birth_date: '', birth_date: '',
@ -83,6 +85,25 @@ export default function Create({ departments, positions }: PageProps) {
{errors.email && <div className="text-red-500 text-xs">{errors.email}</div>} {errors.email && <div className="text-red-500 text-xs">{errors.email}</div>}
</div> </div>
<div className="space-y-2">
<Label>Password Login</Label>
<Input
type="password"
value={data.password}
onChange={e => setData('password', e.target.value)}
/>
{errors.password && <div className="text-red-500 text-xs">{errors.password}</div>}
</div>
<div className="space-y-2">
<Label>Konfirmasi Password</Label>
<Input
type="password"
value={data.password_confirmation}
onChange={e => setData('password_confirmation', e.target.value)}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>No. Telepon</Label> <Label>No. Telepon</Label>
<Input <Input

View File

@ -33,6 +33,8 @@ export default function Create({ employees }: PageProps) {
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
employee_id: '', employee_id: '',
email: '', email: '',
password: '',
password_confirmation: '',
role: '', role: '',
}); });
@ -104,9 +106,6 @@ export default function Create({ employees }: PageProps) {
<div className="rounded-md bg-muted/40 border px-4 py-3 text-sm"> <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="text-muted-foreground text-xs mb-1">Nama akun akan dibuat sebagai:</p>
<p className="font-semibold">{selectedEmployee.name}</p> <p className="font-semibold">{selectedEmployee.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Password default: <span className="font-mono font-medium">password</span>
</p>
</div> </div>
)} )}
@ -126,6 +125,32 @@ export default function Create({ employees }: PageProps) {
)} )}
</div> </div>
<div className="space-y-2">
<Label>
Password <span className="text-red-500">*</span>
</Label>
<Input
type="password"
placeholder="Minimal 8 karakter"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
/>
{errors.password && (
<p className="text-xs text-red-500">{errors.password}</p>
)}
</div>
<div className="space-y-2">
<Label>
Konfirmasi Password <span className="text-red-500">*</span>
</Label>
<Input
type="password"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
/>
</div>
{/* Role */} {/* Role */}
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>

View File

@ -2,10 +2,12 @@
use App\Http\Controllers\Admin\DepartmentController; use App\Http\Controllers\Admin\DepartmentController;
use App\Http\Controllers\Admin\EmployeeController; use App\Http\Controllers\Admin\EmployeeController;
use App\Http\Controllers\Admin\AttendanceController;
use App\Http\Controllers\Admin\PayrollController; use App\Http\Controllers\Admin\PayrollController;
use App\Http\Controllers\Admin\PositionController; use App\Http\Controllers\Admin\PositionController;
use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use Inertia\Inertia;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// Redirect root ke halaman login // Redirect root ke halaman login
@ -22,6 +24,7 @@
Route::resource('departments', DepartmentController::class); Route::resource('departments', DepartmentController::class);
Route::resource('positions', PositionController::class); Route::resource('positions', PositionController::class);
Route::resource('employees', EmployeeController::class); Route::resource('employees', EmployeeController::class);
Route::resource('attendance', AttendanceController::class)->only(['index', 'create', 'store']);
Route::resource('payrolls', PayrollController::class)->only(['index', 'create', 'store', 'show']); Route::resource('payrolls', PayrollController::class)->only(['index', 'create', 'store', 'show']);
Route::get('users', [UserController::class, 'index'])->name('users.index'); Route::get('users', [UserController::class, 'index'])->name('users.index');