feat: implement employee management module

This commit is contained in:
IlhamIslamy 2026-02-15 00:56:25 +07:00
parent d4fdf35a3c
commit b62df688b7
14 changed files with 629 additions and 22 deletions

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Employee;
use App\Models\User;
use App\Http\Requests\StoreEmployeeRequest;
use App\Http\Requests\UpdateEmployeeRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
class EmployeeController extends Controller
{
public function index()
{
$employees = Employee::with('user')
->latest()
->paginate(10);
return Inertia::render('admin/employees/index', [
'employees' => $employees
]);
}
public function create()
{
return Inertia::render('admin/employees/create');
}
public function store(StoreEmployeeRequest $request)
{
DB::transaction(function () use ($request) {
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role' => 'employee',
]);
$user->employee()->create([
'nip' => $request->nip,
'position' => $request->position,
'status' => $request->status,
'join_date' => $request->join_date,
]);
});
return redirect()->route('admin.employees.index')
->with('success', 'Karyawan berhasil ditambahkan.');
}
public function edit(Employee $employee)
{
$employee->load('user');
return Inertia::render('admin/employees/edit', [
'employee' => $employee
]);
}
public function update(UpdateEmployeeRequest $request, Employee $employee)
{
DB::transaction(function () use ($request, $employee) {
// Update User Data
$userData = [
'name' => $request->name,
'email' => $request->email,
];
if ($request->filled('password')) {
$userData['password'] = Hash::make($request->password);
}
$employee->user->update($userData);
// Update Employee Data
$employee->update([
'nip' => $request->nip,
'position' => $request->position,
'status' => $request->status,
'join_date' => $request->join_date,
]);
});
return redirect()->route('admin.employees.index')
->with('success', 'Data karyawan diperbarui.');
}
public function destroy(Employee $employee)
{
$employee->user->delete();
return back()->with('success', 'Karyawan dihapus.');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEmployeeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'nip' => 'required|string|unique:employees,nip',
'position' => 'required|string',
'status' => 'required|in:PKWT,PKWTT',
'join_date' => 'required|date',
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEmployeeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$employeeId = $this->route('employee')->id;
$userId = $this->route('employee')->user_id;
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $userId,
'nip' => 'required|string|unique:employees,nip,' . $employeeId,
'position' => 'required|string',
'status' => 'required|in:PKWT,PKWTT',
'join_date' => 'required|date',
// Password opsional saat update
'password' => 'nullable|string|min:8',
];
}
}

24
app/Models/Employee.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Employee extends Model
{
protected $fillable = [
'user_id',
'nip',
'position',
'status',
'phone',
'address',
'join_date'
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -22,6 +22,7 @@ class User extends Authenticatable
'name',
'email',
'password',
'role',
];
/**
@ -36,6 +37,16 @@ class User extends Authenticatable
'remember_token',
];
public function employee()
{
return $this->hasOne(Employee::class);
}
public function hasRole($role)
{
return $this->role === $role;
}
/**
* Get the attributes that should be cast.
*

View File

@ -17,6 +17,7 @@ public function up(): void
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->enum('role', ['admin', 'employee'])->default('employee');
$table->rememberToken();
$table->timestamps();
});

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('employees', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('nip')->unique();
$table->string('position');
$table->enum('status', ['PKWT', 'PKWTT']);
$table->string('phone')->nullable();
$table->text('address')->nullable();
$table->date('join_date');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('employees');
}
};

View File

@ -3,8 +3,8 @@
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
@ -13,11 +13,12 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
User::create([
'name' => 'Super Admin',
'email' => 'admin@zhanhui.com',
'password' => Hash::make('password123'),
'role' => 'admin',
]);
}
}
}

View File

@ -8,7 +8,7 @@ export default function AppLogo() {
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
Laravel Starter Kit
SDM App
</span>
</div>
</>

View File

@ -1,5 +1,5 @@
import { Link } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
import { BookOpen, Folder, LayoutGrid, Users } from 'lucide-react';
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
@ -22,20 +22,25 @@ const mainNavItems: NavItem[] = [
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Manajemen Karyawan',
href: '/admin/employees',
icon: Users,
},
];
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
},
];
// const footerNavItems: NavItem[] = [
// {
// title: 'Repository',
// href: 'https://github.com/laravel/react-starter-kit',
// icon: Folder,
// },
// {
// title: 'Documentation',
// href: 'https://laravel.com/docs/starter-kits#react',
// icon: BookOpen,
// },
// ];
export function AppSidebar() {
return (
@ -57,7 +62,7 @@ export function AppSidebar() {
</SidebarContent>
<SidebarFooter>
<NavFooter items={footerNavItems} className="mt-auto" />
{/* <NavFooter items={footerNavItems} className="mt-auto" /> */}
<NavUser />
</SidebarFooter>
</Sidebar>

View File

@ -0,0 +1,100 @@
import { Head, useForm, Link } from '@inertiajs/react';
import React from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, 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 AppLayout from '@/layouts/app-layout';
export default function Create() {
const { data, setData, post, processing, errors } = useForm({
name: '',
email: '',
password: '',
nip: '',
position: '',
status: 'PKWT',
join_date: '',
});
const submit = (e: FormEvent) => {
e.preventDefault();
post('/admin/employees');
};
return (
<AppLayout>
<Head title="Tambah Karyawan" />
<div className="p-8 max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Tambah Karyawan</CardTitle>
<CardDescription>Masukkan data detail karyawan baru.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label>Nama Lengkap</Label>
<Input value={data.name} onChange={e => setData('name', e.target.value)} />
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={data.email} onChange={e => setData('email', e.target.value)} />
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
<div className="space-y-2">
<Label>Password Default</Label>
<Input type="password" value={data.password} onChange={e => setData('password', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>NIP</Label>
<Input value={data.nip} onChange={e => setData('nip', e.target.value)} />
</div>
<div className="space-y-2">
<Label>Tanggal Masuk</Label>
<Input type="date" value={data.join_date} onChange={e => setData('join_date', e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Jabatan</Label>
<Select onValueChange={(val) => setData('position', val)}>
<SelectTrigger><SelectValue placeholder="Pilih" /></SelectTrigger>
<SelectContent>
<SelectItem value="Staff">Staff</SelectItem>
<SelectItem value="Supervisor">Supervisor</SelectItem>
<SelectItem value="Manager">Manager</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select onValueChange={(val) => setData('status', val)}>
<SelectTrigger><SelectValue placeholder="Pilih" /></SelectTrigger>
<SelectContent>
<SelectItem value="PKWT">PKWT</SelectItem>
<SelectItem value="PKWTT">PKWTT</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" asChild><Link href="/admin/employees">Batal</Link></Button>
<Button type="submit" disabled={processing}>Simpan</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,163 @@
import { Head, useForm, Link } from '@inertiajs/react';
import { Separator } from '@radix-ui/react-separator';
import React from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, 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 AppLayout from '@/layouts/app-layout';
// Definisi tipe data props untuk TypeScript
interface EmployeeProps {
employee: {
id: number;
nip: string;
position: string;
status: string;
join_date: string;
user: {
name: string;
email: string;
} | null;
};
}
export default function Edit({ employee }: EmployeeProps) {
// Inisialisasi form dengan data dari database
const { data, setData, put, processing, errors } = useForm({
name: employee.user?.name || '',
email: employee.user?.email || '',
password: '', // Kosongkan untuk keamanan
nip: employee.nip || '',
position: employee.position || '',
status: employee.status || 'PKWT',
join_date: employee.join_date || '',
});
const submit = (e: FormEvent) => {
e.preventDefault();
put(`/admin/employees/${employee.id}`);
};
return (
<AppLayout>
<Head title={`Edit Karyawan - ${employee.user?.name}`} />
<div className="p-8 max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Edit Karyawan</CardTitle>
<CardDescription>
Perbarui informasi data diri dan kepegawaian karyawan.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
{/* Data Profil */}
<div className="space-y-2">
<Label htmlFor="name">Nama Lengkap</Label>
<Input
id="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password Baru (Kosongkan jika tidak diganti)</Label>
<Input
id="password"
type="password"
value={data.password}
onChange={e => setData('password', e.target.value)}
/>
{errors.password && <p className="text-xs text-red-500">{errors.password}</p>}
</div>
<Separator className="my-4" />
{/* Data Kepegawaian */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="nip">NIP</Label>
<Input
id="nip"
value={data.nip}
onChange={e => setData('nip', e.target.value)}
/>
{errors.nip && <p className="text-xs text-red-500">{errors.nip}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="join_date">Tanggal Masuk</Label>
<Input
id="join_date"
type="date"
value={data.join_date}
onChange={e => setData('join_date', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Jabatan</Label>
<Select
value={data.position}
onValueChange={(val) => setData('position', val)}
>
<SelectTrigger>
<SelectValue placeholder="Pilih Jabatan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Staff">Staff</SelectItem>
<SelectItem value="Supervisor">Supervisor</SelectItem>
<SelectItem value="Manager">Manager</SelectItem>
<SelectItem value="HRD">HRD</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status Kerja</Label>
<Select
value={data.status}
onValueChange={(val) => setData('status', val)}
>
<SelectTrigger>
<SelectValue placeholder="Pilih Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PKWT">PKWT</SelectItem>
<SelectItem value="PKWTT">PKWTT</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" asChild>
<Link href="/admin/employees">Batal</Link>
</Button>
<Button type="submit" disabled={processing}>
Simpan Perubahan
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,97 @@
import { Head, Link, usePage } from '@inertiajs/react';
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
interface Employee {
id: number;
nip: string;
position: string;
status: string;
user: {
name: string;
email: string;
} | null;
}
interface PageProps {
employees: {
data: Employee[];
};
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
};
}
export default function Index({ employees }: PageProps) {
const { flash } = usePage().props as unknown as SharedData;
return (
<AppLayout>
<Head title="Manajemen Karyawan" />
<div className="p-8">
{flash?.success && (
<div className="mb-4 p-4 bg-green-50 text-green-700 border rounded-md">
{flash.success}
</div>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Daftar Karyawan</CardTitle>
<Button asChild>
<Link href="/admin/employees/create">Tambah Karyawan</Link>
</Button>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="pb-4 font-medium">Karyawan</th>
<th className="pb-4 font-medium">NIP & Jabatan</th>
<th className="pb-4 font-medium">Status</th>
<th className="pb-4 text-right font-medium">Aksi</th>
</tr>
</thead>
<tbody>
{employees.data.map((employee) => (
<tr key={employee.id} className="border-b">
<td className="py-4">
<div className="font-bold">{employee.user?.name}</div>
<div className="text-xs text-muted-foreground">{employee.user?.email}</div>
</td>
<td className="py-4">
<div>{employee.nip}</div>
<Badge variant="outline">{employee.position}</Badge>
</td>
<td className="py-4">
<Badge variant={employee.status === 'PKWTT' ? 'default' : 'secondary'}>
{employee.status}
</Badge>
</td>
<td className="py-4 text-right">
<Button variant="ghost" asChild>
<Link href={`/admin/employees/${employee.id}/edit`}>Edit</Link>
</Button>
<Button variant="ghost" className="text-red-600" asChild>
<Link href={`/admin/employees/${employee.id}`} method="delete" as="button">Hapus</Link>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\EmployeeController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Fortify\Features;
@ -14,4 +15,7 @@
return Inertia::render('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('employees', EmployeeController::class);
});
require __DIR__.'/settings.php';