feat absensi
This commit is contained in:
parent
859dbe13a1
commit
b9ace087ee
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "HRIS_App",
|
"name": "HR_Management__App",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue