Feat: Dashboard, Manajemen Positions, and Manajemen Departments

This commit is contained in:
IlhamIslamy 2026-02-26 03:17:08 +07:00
parent 0ef81fb590
commit ae9e0a2eb0
25 changed files with 3950 additions and 324 deletions

View File

@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DepartmentController extends Controller
{
public function index()
{
return Inertia::render('admin/departments/index', [
'departments' => Department::withCount('employees')->latest()->get(),
]);
}
public function create()
{
return Inertia::render('admin/departments/create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:departments,name',
'description' => 'nullable|string',
]);
Department::create($validated);
return redirect()->to('/admin/departments')
->with('success', 'Departemen berhasil ditambahkan.');
}
public function edit(Department $department)
{
return Inertia::render('admin/departments/edit', [
'department' => $department
]);
}
public function update(Request $request, Department $department)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:departments,name,' . $department->id,
'description' => 'nullable|string',
]);
$department->update($validated);
return redirect()->to('/admin/departments')
->with('success', 'Departemen berhasil diperbarui.');
}
public function destroy(Department $department)
{
// Cek jika departemen masih punya karyawan
if ($department->employees()->count() > 0) {
return back()->with('error', 'Gagal hapus! Departemen ini masih memiliki karyawan.');
}
$department->delete();
return back()->with('success', 'Departemen berhasil dihapus.');
}
}

View File

@ -3,85 +3,123 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Employee; use App\Models\Employee;
use App\Models\Position;
use App\Models\User; use App\Models\User;
use App\Http\Requests\StoreEmployeeRequest; use Illuminate\Http\Request;
use App\Http\Requests\UpdateEmployeeRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
class EmployeeController extends Controller class EmployeeController extends Controller
{ {
public function index() public function index(Request $request)
{ {
$employees = Employee::with('user') $query = Employee::with(['user', 'department', 'position']);
->latest()
->paginate(10); if ($request->search) {
$query->where('nip', 'like', '%' . $request->search . '%')
->orWhereHas('user', function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%');
});
}
if ($request->department_id) {
$query->where('department_id', $request->department_id);
}
return Inertia::render('admin/employees/index', [ return Inertia::render('admin/employees/index', [
'employees' => $employees 'employees' => $query->latest()->paginate(10)->withQueryString(),
'departments' => Department::all(),
'filters' => $request->only(['search', 'department_id']),
]); ]);
} }
public function create() public function create()
{ {
return Inertia::render('admin/employees/create'); return Inertia::render('admin/employees/create', [
'departments' => Department::all(),
'positions' => Position::all(),
]);
} }
public function store(StoreEmployeeRequest $request) public function store(Request $request)
{ {
DB::transaction(function () use ($request) { $validated = $request->validate([
$user = User::create([ 'name' => 'required|string|max:255',
'name' => $request->name, 'email' => 'required|email|unique:users,email',
'email' => $request->email, 'nip' => 'required|string|unique:employees,nip',
'password' => Hash::make($request->password), 'gender' => 'required|in:L,P',
'role' => 'employee', 'place_of_birth' => 'nullable|string|max:255',
]); 'birth_date' => 'required|date',
'address' => 'nullable|string',
'phone_number' => 'nullable|string',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
]);
$user->employee()->create([ $user = User::create([
'nip' => $request->nip, 'name' => $validated['name'],
'position' => $request->position, 'email' => $validated['email'],
'status' => $request->status, 'password' => Hash::make('password123'),
'join_date' => $request->join_date, ]);
]);
}); Employee::create([
'user_id' => $user->id,
'nip' => $validated['nip'],
'name' => $validated['name'],
'gender' => $validated['gender'],
'place_of_birth' => $validated['place_of_birth'],
'birth_date' => $validated['birth_date'],
'address' => $validated['address'],
'phone_number' => $validated['phone_number'],
'department_id' => $validated['department_id'],
'position_id' => $validated['position_id'],
'status' => $validated['status'],
'join_date' => $validated['join_date'],
]);
return redirect()->route('admin.employees.index') return redirect()->route('admin.employees.index')
->with('success', 'Karyawan berhasil ditambahkan.'); ->with('success', 'Data karyawan berhasil ditambahkan.');
} }
public function edit(Employee $employee) public function edit(Employee $employee)
{ {
$employee->load('user'); $employee->load(['user', 'department', 'position']);
return Inertia::render('admin/employees/edit', [ return Inertia::render('admin/employees/edit', [
'employee' => $employee 'employee' => $employee,
'departments' => Department::all(),
'positions' => Position::all(),
]); ]);
} }
public function update(UpdateEmployeeRequest $request, Employee $employee) public function update(Request $request, Employee $employee)
{ {
DB::transaction(function () use ($request, $employee) { $validated = $request->validate([
// Update User Data 'name' => 'required|string|max:255',
$userData = [ 'email' => ['required', 'email', Rule::unique('users')->ignore($employee->user_id)],
'name' => $request->name, 'nip' => ['required', 'string', Rule::unique('employees')->ignore($employee->id)],
'email' => $request->email, 'gender' => 'required|in:L,P',
]; 'place_of_birth' => 'nullable|string|max:255',
'birth_date' => 'required|date',
'address' => 'nullable|string',
'phone_number' => 'nullable|string',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
]);
if ($request->filled('password')) { $employee->user->update([
$userData['password'] = Hash::make($request->password); 'name' => $validated['name'],
} 'email' => $validated['email'],
]);
$employee->user->update($userData); $employee->update($validated);
// 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') return redirect()->route('admin.employees.index')
->with('success', 'Data karyawan diperbarui.'); ->with('success', 'Data karyawan diperbarui.');
@ -89,7 +127,7 @@ public function update(UpdateEmployeeRequest $request, Employee $employee)
public function destroy(Employee $employee) public function destroy(Employee $employee)
{ {
$employee->user->delete(); $employee->user->delete();
return back()->with('success', 'Karyawan dihapus.'); return back()->with('success', 'Karyawan dihapus.');
} }
} }

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Position;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PositionController extends Controller
{
public function index()
{
return Inertia::render('admin/positions/index', [
'positions' => Position::withCount('employees')->latest()->get(),
]);
}
public function create()
{
return Inertia::render('admin/positions/create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:positions,name',
]);
Position::create($validated);
return redirect()->to('/admin/positions')
->with('success', 'Jabatan berhasil ditambahkan.');
}
public function edit(Position $position)
{
return Inertia::render('admin/positions/edit', [
'position' => $position
]);
}
public function update(Request $request, Position $position)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:positions,name,' . $position->id,
]);
$position->update($validated);
return redirect()->to('/admin/positions')
->with('success', 'Jabatan berhasil diperbarui.');
}
public function destroy(Position $position)
{
if ($position->employees()->count() > 0) {
return back()->with('error', 'Gagal hapus! Masih ada karyawan dengan jabatan ini.');
}
$position->delete();
return back()->with('success', 'Jabatan berhasil dihapus.');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use App\Models\Department;
use App\Models\Employee;
use App\Models\Position;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DashboardController extends Controller
{
public function index()
{
// Ringkasan Data
$stats = [
'total_employees' => Employee::count(),
'total_departments' => Department::count(),
'total_positions' => Position::count(),
];
// Data Grafik Gender (Pie Chart)
$genderData = Employee::selectRaw('gender, count(*) as total')
->groupBy('gender')
->get()
->map(fn($item) => [
'name' => $item->gender == 'L' ? 'Laki-laki' : 'Perempuan',
'value' => $item->total,
'fill' => $item->gender == 'L' ? '#3b82f6' : '#ec4899',
]);
// Data Grafik Departemen (Bar Chart)
$deptData = Department::withCount('employees')
->having('employees_count', '>', 0)
->get()
->map(fn($item) => [
'name' => $item->name,
'employees' => $item->employees_count,
]);
// Karyawan Terbaru (Table)
$latestEmployees = Employee::with(['department', 'position', 'user'])
->latest('join_date')
->take(5)
->get();
return Inertia::render('dashboard', [
'stats' => $stats,
'genderData' => $genderData,
'deptData' => $deptData,
'latestEmployees' => $latestEmployees,
]);
}
}

18
app/Models/Department.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Department extends Model
{
use HasFactory;
protected $fillable = ['name', 'description'];
public function employees()
{
return $this->hasMany(Employee::class);
}
}

View File

@ -2,23 +2,43 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Employee extends Model class Employee extends Model
{ {
use HasFactory;
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'nip', 'nip',
'position', 'name',
'status', 'gender',
'phone', 'place_of_birth',
'birth_date',
'address', 'address',
'phone_number',
'department_id',
'position_id',
'status',
'join_date' 'join_date'
]; ];
public function user(): BelongsTo // Relasi ke User
public function user()
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
}
// Relasi ke Departemen
public function department()
{
return $this->belongsTo(Department::class);
}
// Relasi ke Jabatan
public function position()
{
return $this->belongsTo(Position::class);
}
}

18
app/Models/Position.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Position extends Model
{
use HasFactory;
protected $fillable = ['name'];
public function employees()
{
return $this->hasMany(Employee::class);
}
}

View File

@ -9,17 +9,29 @@
/** /**
* Run the migrations. * Run the migrations.
*/ */
public function up(): void public function up()
{ {
Schema::create('employees', function (Blueprint $table) { Schema::create('employees', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('nip')->unique();
$table->string('position'); // Data Pribadi
$table->enum('status', ['PKWT', 'PKWTT']); $table->string('nip')->unique();
$table->string('phone')->nullable(); $table->string('name');
$table->enum('gender', ['L', 'P']);
$table->string('place_of_birth')->nullable();
$table->date('birth_date');
$table->text('address')->nullable(); $table->text('address')->nullable();
$table->date('join_date'); $table->string('phone_number')->nullable();
// Data Pekerjaan
$table->foreignId('department_id')->constrained()->onDelete('restrict');
$table->foreignId('position_id')->constrained()->onDelete('restrict');
// Status & Tanggal
$table->enum('status', ['PKWT', 'PKWTT', 'Magang']);
$table->date('join_date');
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -0,0 +1,29 @@
<?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()
{
Schema::create('departments', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('departments');
}
};

View File

@ -0,0 +1,27 @@
<?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()
{
Schema::create('positions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('positions');
}
};

View File

@ -2,6 +2,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Department;
use App\Models\Position;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -20,5 +22,17 @@ public function run(): void
'role' => 'admin', 'role' => 'admin',
]); ]);
// Seed departments
Department::create(['name' => 'Information Technology', 'description' => 'Bagian IT & Development']);
Department::create(['name' => 'Human Resources', 'description' => 'Bagian Kepegawaian']);
Department::create(['name' => 'Finance', 'description' => 'Bagian Keuangan']);
Department::create(['name' => 'Marketing', 'description' => 'Bagian Pemasaran']);
// Seed positions
Position::create(['name' => 'General Manager']);
Position::create(['name' => 'Manager']);
Position::create(['name' => 'Supervisor']);
Position::create(['name' => 'Staff']);
Position::create(['name' => 'Intern']);
} }
} }

2209
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -53,12 +53,15 @@
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"laravel-vite-plugin": "^2.0", "laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"recharts": "^3.7.0",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"use-debounce": "^10.1.0",
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -1,5 +1,5 @@
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
import { LayoutGrid, Users } from 'lucide-react'; import { LayoutGrid, Users, Briefcase, Building2} 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 {
@ -18,13 +18,23 @@ import AppLogo from './app-logo';
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {
title: 'Dashboard', title: 'Dashboard',
href: dashboard(), href: '/dashboard',
icon: LayoutGrid, icon: LayoutGrid,
}, },
{ {
title: 'Manajemen Karyawan', title: 'Manajemen Karyawan',
href: '/admin/employees', href: '/admin/employees',
icon: Users, icon: Users,
},
{
title: 'Manajemen Departemen',
href: '/admin/departments',
icon: Building2,
},
{
title: 'Manajemen Jabatan',
href: '/admin/positions',
icon: Briefcase,
}, },
]; ];

View File

@ -0,0 +1,81 @@
import { Head, useForm, Link } from '@inertiajs/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
export default function Create() {
const { data, setData, post, processing, errors } = useForm({
name: '',
description: '',
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
post('/admin/departments');
};
return (
<AppLayout>
<Head title="Tambah Departemen" />
<div className="p-4 md:p-8 w-full">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Tambah Departemen</h2>
<p className="text-muted-foreground">Buat divisi baru dalam perusahaan.</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin/departments">Kembali</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Form Departemen</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<form onSubmit={submit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Nama Departemen <span className="text-red-500">*</span></Label>
<Input
placeholder="Contoh: Information Technology"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
</div>
<div className="space-y-2">
<Label>Deskripsi Singkat</Label>
<Input
placeholder="Keterangan fungsi departemen..."
value={data.description}
onChange={e => setData('description', e.target.value)}
/>
{errors.description && <div className="text-red-500 text-xs">{errors.description}</div>}
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button type="button" variant="ghost" asChild>
<Link href="/admin/departments">Batal</Link>
</Button>
<Button type="submit" disabled={processing}>
Simpan Departemen
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,88 @@
import { Head, useForm, Link } from '@inertiajs/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
interface Department {
id: number;
name: string;
description: string;
}
interface PageProps {
department: Department;
}
export default function Edit({ department }: PageProps) {
const { data, setData, put, processing, errors } = useForm({
name: department.name,
description: department.description || '',
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
put(`/admin/departments/${department.id}`);
};
return (
<AppLayout>
<Head title="Edit Departemen" />
<div className="p-4 md:p-8 w-full">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Edit Departemen</h2>
<p className="text-muted-foreground">Perbarui informasi divisi.</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin/departments">Kembali</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Form Edit Departemen</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<form onSubmit={submit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Nama Departemen</Label>
<Input
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
</div>
<div className="space-y-2">
<Label>Deskripsi Singkat</Label>
<Input
value={data.description}
onChange={e => setData('description', e.target.value)}
/>
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button type="button" variant="ghost" asChild>
<Link href="/admin/departments">Batal</Link>
</Button>
<Button type="submit" disabled={processing}>
Simpan Perubahan
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,152 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Head, Link, usePage } from '@inertiajs/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"; // Import Tooltip
import AppLayout from '@/layouts/app-layout';
interface Department {
id: number;
name: string;
description: string;
employees_count: number;
}
interface PageProps {
departments: Department[];
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
error?: string;
};
}
export default function Index({ departments }: PageProps) {
const { flash } = usePage<any>().props as SharedData;
return (
<AppLayout>
<Head title="Manajemen Departemen" />
<div className="p-4 md:p-8 w-full space-y-4">
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Data Departemen</h2>
<p className="text-muted-foreground">Kelola struktur organisasi dan unit kerja.</p>
</div>
<Button asChild>
<Link href="/admin/departments/create">+ Tambah Departemen</Link>
</Button>
</div>
<Card>
<CardHeader className="pb-4">
<CardTitle>Daftar Departemen</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<table className="w-full text-sm text-left">
<thead className="text-muted-foreground bg-zinc-50/50">
<tr className="border-b">
<th className="h-12 px-4 font-medium">Nama Departemen</th>
<th className="h-12 px-4 font-medium">Deskripsi</th>
<th className="h-12 px-4 font-medium text-center">Total Karyawan</th>
<th className="h-12 px-4 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody>
{departments.length > 0 ? (
departments.map((dept) => (
<tr key={dept.id} className="border-b hover:bg-zinc-50">
<td className="p-4 font-bold text-gray-900">
{dept.name}
</td>
<td className="p-4 text-muted-foreground">
{dept.description || '-'}
</td>
<td className="p-4 text-center">
<span className={`inline-flex items-center justify-center px-2.5 py-0.5 rounded-full text-xs font-medium ${dept.employees_count > 0 ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'}`}>
{dept.employees_count}
</span>
</td>
<td className="p-4 text-right space-x-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/departments/${dept.id}/edit`}>Edit</Link>
</Button>
{dept.employees_count > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} className="cursor-not-allowed inline-block">
<Button
variant="ghost"
size="sm"
disabled
className="text-red-300 hover:text-red-300 hover:bg-transparent opacity-50"
>
Hapus
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="bg-red-50 text-red-700 border-red-200">
<p className="font-semibold">Akses Ditolak</p>
<p className="text-xs">Masih ada {dept.employees_count} karyawan aktif di sini.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href={`/admin/departments/${dept.id}`}
method="delete"
as="button"
onClick={(e) => {
if (!confirm('Hapus departemen ini?')) e.preventDefault();
}}
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 text-red-600 hover:bg-red-50 transition-colors"
>
Hapus
</Link>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="p-8 text-center text-muted-foreground">
Belum ada data departemen.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -1,96 +1,232 @@
import { Head, useForm, Link } from '@inertiajs/react'; import { Head, useForm, Link } from '@inertiajs/react';
import React from 'react'; import React from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
export default function Create() { interface PageProps {
departments: { id: number; name: string }[];
positions: { id: number; name: string }[];
errors: Partial<Record<string, string>>;
}
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: '', nip: '',
nip: '', gender: '',
position: '', birth_date: '',
status: 'PKWT', place_of_birth: '',
address: '',
phone_number: '',
department_id: '',
position_id: '',
status: '',
join_date: '', join_date: '',
}); });
const submit = (e: FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
post('/admin/employees'); post('/admin/employees');
}; };
return ( return (
<AppLayout> <AppLayout>
<Head title="Tambah Karyawan" /> <Head title="Tambah Karyawan Baru" />
<div className="p-8 max-w-2xl mx-auto">
<div className="p-4 md:p-8 max-w-5xl mx-auto">
<Button variant="outline" asChild className="mb-6">
<Link href="/admin/employees"> Kembali</Link>
</Button>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Tambah Karyawan</CardTitle> <CardTitle>Form Data Karyawan</CardTitle>
<CardDescription>Masukkan data detail karyawan baru.</CardDescription> <p className="text-sm text-muted-foreground">Lengkapi form di bawah ini untuk menambahkan karyawan baru.</p>
</CardHeader> </CardHeader>
<CardContent> <Separator />
<form onSubmit={submit} className="space-y-4"> <CardContent className="pt-6">
<div className="space-y-2"> <form onSubmit={submit} className="space-y-8">
<Label>Nama Lengkap</Label>
<Input value={data.name} onChange={e => setData('name', e.target.value)} /> {/* INFORMASI PRIBADI */}
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>} <div className="space-y-4">
</div> <h3 className="text-lg font-medium flex items-center gap-2">
<span className="bg-primary/10 text-primary w-6 h-6 rounded-full flex items-center justify-center text-xs">1</span>
Informasi Pribadi
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Kolom Kiri */}
<div className="space-y-4">
<div className="space-y-2">
<Label>Nama Lengkap</Label>
<Input
placeholder="Nama sesuai KTP"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Email</Label> <Label>Email</Label>
<Input type="email" value={data.email} onChange={e => setData('email', e.target.value)} /> <Input
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>} type="email"
</div> value={data.email}
onChange={e => setData('email', e.target.value)}
/>
{errors.email && <div className="text-red-500 text-xs">{errors.email}</div>}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Password Default</Label> <Label>No. Telepon</Label>
<Input type="password" value={data.password} onChange={e => setData('password', e.target.value)} /> <Input
</div> value={data.phone_number}
onChange={e => setData('phone_number', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> {/* Kolom Kanan */}
<div className="space-y-2"> <div className="space-y-4">
<Label>NIP</Label> <div className="grid grid-cols-2 gap-4">
<Input value={data.nip} onChange={e => setData('nip', e.target.value)} /> <div className="space-y-2">
</div> <Label>Tempat Lahir</Label>
<div className="space-y-2"> <Input
<Label>Tanggal Masuk</Label> value={data.place_of_birth}
<Input type="date" value={data.join_date} onChange={e => setData('join_date', e.target.value)} /> onChange={e => setData('place_of_birth', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Tanggal Lahir</Label>
<Input
type="date"
value={data.birth_date}
onChange={e => setData('birth_date', e.target.value)}
/>
{errors.birth_date && <div className="text-red-500 text-xs">{errors.birth_date}</div>}
</div>
</div>
<div className="space-y-2">
<Label>Jenis Kelamin</Label>
<Select onValueChange={(val) => setData('gender', val)}>
<SelectTrigger><SelectValue placeholder="Pilih Gender" /></SelectTrigger>
<SelectContent>
<SelectItem value="L">Laki-laki</SelectItem>
<SelectItem value="P">Perempuan</SelectItem>
</SelectContent>
</Select>
{errors.gender && <div className="text-red-500 text-xs">{errors.gender}</div>}
</div>
<div className="space-y-2">
<Label>Alamat Lengkap</Label>
<Input
value={data.address}
onChange={e => setData('address', e.target.value)}
/>
</div>
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <Separator />
<div className="space-y-2">
<Label>Jabatan</Label> {/* DATA KEPEGAWAIAN */}
<Select onValueChange={(val) => setData('position', val)}> <div className="space-y-4">
<SelectTrigger><SelectValue placeholder="Pilih" /></SelectTrigger> <h3 className="text-lg font-medium flex items-center gap-2">
<SelectContent> <span className="bg-primary/10 text-primary w-6 h-6 rounded-full flex items-center justify-center text-xs">2</span>
<SelectItem value="Staff">Staff</SelectItem> Data Kepegawaian
<SelectItem value="Supervisor">Supervisor</SelectItem> </h3>
<SelectItem value="Manager">Manager</SelectItem>
</SelectContent> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
</Select> {/* Kolom Kiri */}
</div> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Status</Label> <Label>Nomor Induk (NIP)</Label>
<Select onValueChange={(val) => setData('status', val)}> <Input
<SelectTrigger><SelectValue placeholder="Pilih" /></SelectTrigger> placeholder="Contoh: 2024001"
<SelectContent> value={data.nip}
<SelectItem value="PKWT">PKWT</SelectItem> onChange={e => setData('nip', e.target.value)}
<SelectItem value="PKWTT">PKWTT</SelectItem> />
</SelectContent> {errors.nip && <div className="text-red-500 text-xs">{errors.nip}</div>}
</Select> </div>
<div className="space-y-2">
<Label>Departemen</Label>
<Select onValueChange={(val) => setData('department_id', val)}>
<SelectTrigger><SelectValue placeholder="Pilih Departemen" /></SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>{dept.name}</SelectItem>
))}
</SelectContent>
</Select>
{errors.department_id && <div className="text-red-500 text-xs">Wajib diisi</div>}
</div>
</div>
{/* Kolom Kanan */}
<div className="space-y-4">
<div className="space-y-2">
<Label>Jabatan</Label>
<Select onValueChange={(val) => setData('position_id', val)}>
<SelectTrigger><SelectValue placeholder="Pilih Jabatan" /></SelectTrigger>
<SelectContent>
{positions.map((pos) => (
<SelectItem key={pos.id} value={String(pos.id)}>{pos.name}</SelectItem>
))}
</SelectContent>
</Select>
{errors.position_id && <div className="text-red-500 text-xs">Wajib diisi</div>}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select onValueChange={(val) => setData('status', val)}>
<SelectTrigger><SelectValue placeholder="Pilih Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="PKWT">PKWT (Kontrak)</SelectItem>
<SelectItem value="PKWTT">PKWTT (Tetap)</SelectItem>
<SelectItem value="Magang">Magang</SelectItem>
</SelectContent>
</Select>
{errors.status && <div className="text-red-500 text-xs">{errors.status}</div>}
</div>
<div className="space-y-2">
<Label>Tanggal Masuk</Label>
<Input
type="date"
value={data.join_date}
onChange={e => setData('join_date', e.target.value)}
/>
{errors.join_date && <div className="text-red-500 text-xs">{errors.join_date}</div>}
</div>
</div>
</div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-4"> <Separator />
<Button variant="outline" asChild><Link href="/admin/employees">Batal</Link></Button>
<Button type="submit" disabled={processing}>Simpan</Button> {/* TOMBOL AKSI */}
<div className="flex justify-end gap-4 pt-2">
<Button type="button" variant="ghost" asChild>
<Link href="/admin/employees">Batal</Link>
</Button>
<Button type="submit" disabled={processing} className="px-8">
Simpan Data
</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,159 +1,247 @@
import { Head, useForm, Link } from '@inertiajs/react'; import { Head, useForm, Link } from '@inertiajs/react';
import { Separator } from '@radix-ui/react-separator';
import React from 'react'; import React from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
// Definisi tipe data props untuk TypeScript // Definisi Tipe Data Employee yang diterima dari Controller
interface EmployeeProps { interface Employee {
employee: { id: number;
id: number; nip: string;
nip: string; gender: string;
position: string; birth_date: string;
status: string; place_of_birth: string;
join_date: string; address: string;
user: { phone_number: string;
name: string; department_id: number;
email: string; position_id: number;
} | null; status: string;
join_date: string;
user: {
name: string;
email: string;
}; };
} }
export default function Edit({ employee }: EmployeeProps) { interface PageProps {
// Inisialisasi form dengan data dari database employee: Employee;
departments: { id: number; name: string }[];
positions: { id: number; name: string }[];
errors: Partial<Record<string, string>>;
}
export default function Edit({ employee, departments, positions }: PageProps) {
// Inisialisasi Form dengan Data Lama (Pre-filled)
const { data, setData, put, processing, errors } = useForm({ const { data, setData, put, processing, errors } = useForm({
name: employee.user?.name || '', name: employee.user.name,
email: employee.user?.email || '', email: employee.user.email,
password: '', // Kosongkan untuk keamanan nip: employee.nip,
nip: employee.nip || '', gender: employee.gender,
position: employee.position || '', birth_date: employee.birth_date,
status: employee.status || 'PKWT', place_of_birth: employee.place_of_birth || '',
join_date: employee.join_date || '', address: employee.address || '',
phone_number: employee.phone_number || '',
department_id: String(employee.department_id),
position_id: String(employee.position_id),
status: employee.status,
join_date: employee.join_date,
}); });
const submit = (e: FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
put(`/admin/employees/${employee.id}`); put(`/admin/employees/${employee.id}`);
}; };
return ( return (
<AppLayout> <AppLayout>
<Head title={`Edit Karyawan - ${employee.user?.name}`} /> <Head title={`Edit Karyawan: ${employee.user.name}`} />
<div className="p-8 max-w-2xl mx-auto">
<div className="p-4 md:p-8 max-w-5xl mx-auto">
<Button variant="outline" asChild className="mb-6">
<Link href="/admin/employees"> Kembali</Link>
</Button>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Edit Karyawan</CardTitle> <CardTitle>Edit Data Karyawan</CardTitle>
<CardDescription> <p className="text-sm text-muted-foreground">Perbarui informasi karyawan di bawah ini.</p>
Perbarui informasi data diri dan kepegawaian karyawan.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <Separator />
<form onSubmit={submit} className="space-y-4"> <CardContent className="pt-6">
{/* Data Profil */} <form onSubmit={submit} className="space-y-8">
<div className="space-y-2">
<Label htmlFor="name">Nama Lengkap</Label> {/* INFORMASI PRIBADI */}
<Input <div className="space-y-4">
id="name" <h3 className="text-lg font-medium flex items-center gap-2">
value={data.name} <span className="bg-orange-100 text-orange-600 w-6 h-6 rounded-full flex items-center justify-center text-xs">1</span>
onChange={e => setData('name', e.target.value)} Informasi Pribadi
/> </h3>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Kolom Kiri */}
<div 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 && <div className="text-red-500 text-xs">{errors.name}</div>}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label>Email</Label>
<Input <Input
id="email" type="email"
type="email" value={data.email}
value={data.email} onChange={e => setData('email', e.target.value)}
onChange={e => setData('email', e.target.value)} />
/> {errors.email && <div className="text-red-500 text-xs">{errors.email}</div>}
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>} </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password Baru (Kosongkan jika tidak diganti)</Label> <Label>No. Telepon</Label>
<Input <Input
id="password" value={data.phone_number}
type="password" onChange={e => setData('phone_number', e.target.value)}
value={data.password} />
onChange={e => setData('password', e.target.value)} </div>
/> </div>
{errors.password && <p className="text-xs text-red-500">{errors.password}</p>}
</div>
<Separator className="my-4" /> {/* Kolom Kanan */}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tempat Lahir</Label>
<Input
value={data.place_of_birth}
onChange={e => setData('place_of_birth', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Tanggal Lahir</Label>
<Input
type="date"
value={data.birth_date}
onChange={e => setData('birth_date', e.target.value)}
/>
{errors.birth_date && <div className="text-red-500 text-xs">{errors.birth_date}</div>}
</div>
</div>
{/* Data Kepegawaian */} <div className="space-y-2">
<div className="grid grid-cols-2 gap-4"> <Label>Jenis Kelamin</Label>
<div className="space-y-2"> <Select value={data.gender} onValueChange={(val) => setData('gender', val)}>
<Label htmlFor="nip">NIP</Label> <SelectTrigger><SelectValue placeholder="Pilih Gender" /></SelectTrigger>
<Input <SelectContent>
id="nip" <SelectItem value="L">Laki-laki</SelectItem>
value={data.nip} <SelectItem value="P">Perempuan</SelectItem>
onChange={e => setData('nip', e.target.value)} </SelectContent>
/> </Select>
{errors.nip && <p className="text-xs text-red-500">{errors.nip}</p>} {errors.gender && <div className="text-red-500 text-xs">{errors.gender}</div>}
</div> </div>
<div className="space-y-2">
<Label htmlFor="join_date">Tanggal Masuk</Label> <div className="space-y-2">
<Input <Label>Alamat Lengkap</Label>
id="join_date" <Input
type="date" value={data.address}
value={data.join_date} onChange={e => setData('address', e.target.value)}
onChange={e => setData('join_date', e.target.value)} />
/> </div>
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <Separator />
<div className="space-y-2">
<Label>Jabatan</Label> {/* DATA KEPEGAWAIAN */}
<Select <div className="space-y-4">
value={data.position} <h3 className="text-lg font-medium flex items-center gap-2">
onValueChange={(val) => setData('position', val)} <span className="bg-orange-100 text-orange-600 w-6 h-6 rounded-full flex items-center justify-center text-xs">2</span>
> Data Kepegawaian
<SelectTrigger> </h3>
<SelectValue placeholder="Pilih Jabatan" />
</SelectTrigger> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SelectContent> {/* Kolom Kiri */}
<SelectItem value="Staff">Staff</SelectItem> <div className="space-y-4">
<SelectItem value="Supervisor">Supervisor</SelectItem> <div className="space-y-2">
<SelectItem value="Manager">Manager</SelectItem> <Label>Nomor Induk (NIP)</Label>
<SelectItem value="HRD">HRD</SelectItem> <Input
</SelectContent> value={data.nip}
</Select> onChange={e => setData('nip', e.target.value)}
</div> />
<div className="space-y-2"> {errors.nip && <div className="text-red-500 text-xs">{errors.nip}</div>}
<Label>Status Kerja</Label> </div>
<Select
value={data.status} <div className="space-y-2">
onValueChange={(val) => setData('status', val)} <Label>Departemen</Label>
> <Select value={data.department_id} onValueChange={(val) => setData('department_id', val)}>
<SelectTrigger> <SelectTrigger><SelectValue placeholder="Pilih Departemen" /></SelectTrigger>
<SelectValue placeholder="Pilih Status" /> <SelectContent>
</SelectTrigger> {departments.map((dept) => (
<SelectContent> <SelectItem key={dept.id} value={String(dept.id)}>{dept.name}</SelectItem>
<SelectItem value="PKWT">PKWT</SelectItem> ))}
<SelectItem value="PKWTT">PKWTT</SelectItem> </SelectContent>
</SelectContent> </Select>
</Select> </div>
</div>
{/* Kolom Kanan */}
<div className="space-y-4">
<div className="space-y-2">
<Label>Jabatan</Label>
<Select value={data.position_id} onValueChange={(val) => setData('position_id', val)}>
<SelectTrigger><SelectValue placeholder="Pilih Jabatan" /></SelectTrigger>
<SelectContent>
{positions.map((pos) => (
<SelectItem key={pos.id} value={String(pos.id)}>{pos.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select value={data.status} onValueChange={(val) => setData('status', val)}>
<SelectTrigger><SelectValue placeholder="Pilih Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="PKWT">PKWT (Kontrak)</SelectItem>
<SelectItem value="PKWTT">PKWTT (Tetap)</SelectItem>
<SelectItem value="Magang">Magang</SelectItem>
</SelectContent>
</Select>
</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>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-4"> <Separator />
<Button variant="outline" asChild>
{/* TOMBOL AKSI */}
<div className="flex justify-end gap-4 pt-2">
<Button type="button" variant="ghost" asChild>
<Link href="/admin/employees">Batal</Link> <Link href="/admin/employees">Batal</Link>
</Button> </Button>
<Button type="submit" disabled={processing}> <Button type="submit" disabled={processing} className="px-8">
Simpan Perubahan Perbarui Data
</Button> </Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,16 +1,34 @@
import { Head, Link, usePage } from '@inertiajs/react'; /* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react'; import { Head, Link, usePage, router } from '@inertiajs/react';
import React, { useState, useEffect } from 'react';
import { useDebounce } from 'use-debounce';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
interface Employee { interface Employee {
id: number; id: number;
nip: string; nip: string;
position: string;
status: string; status: string;
join_date: string;
position: {
id: number;
name: string;
};
department: {
id: number;
name: string;
};
user: { user: {
name: string; name: string;
email: string; email: string;
@ -20,6 +38,12 @@ interface Employee {
interface PageProps { interface PageProps {
employees: { employees: {
data: Employee[]; data: Employee[];
links: any[];
};
departments: { id: number; name: string }[];
filters: {
search?: string;
department_id?: string;
}; };
[key: string]: unknown; [key: string]: unknown;
} }
@ -27,68 +51,136 @@ interface PageProps {
interface SharedData { interface SharedData {
flash: { flash: {
success?: string; success?: string;
error?: string;
}; };
} }
export default function Index({ employees }: PageProps) { export default function Index({ employees, departments, filters }: PageProps) {
const { flash } = usePage().props as unknown as SharedData; const { flash } = usePage<any>().props as SharedData;
const [search, setSearch] = useState(filters.search || '');
const [departmentId, setDepartmentId] = useState(filters.department_id || 'all');
const [debouncedSearch] = useDebounce(search, 500);
useEffect(() => {
if (debouncedSearch !== filters.search || (departmentId !== 'all' && departmentId !== filters.department_id)) {
router.get(
'/admin/employees',
{
search: debouncedSearch,
department_id: departmentId === 'all' ? '' : departmentId
},
{ preserveState: true, replace: true }
);
}
}, [debouncedSearch, departmentId]);
return ( return (
<AppLayout> <AppLayout>
<Head title="Manajemen Karyawan" /> <Head title="Manajemen Karyawan" />
<div className="p-8"> <div className="p-4 md:p-8 space-y-4">
{flash?.success && ( {flash?.success && (
<div className="mb-4 p-4 bg-green-50 text-green-700 border rounded-md"> <div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success} {flash.success}
</div> </div>
)} )}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle>Daftar Karyawan</CardTitle> <CardTitle className="text-xl font-bold">Daftar Karyawan</CardTitle>
<Button asChild> <Button asChild>
<Link href="/admin/employees/create">Tambah Karyawan</Link> <Link href="/admin/employees/create">+ Tambah Karyawan</Link>
</Button> </Button>
</CardHeader> </CardHeader>
<Separator /> <Separator />
<CardContent className="pt-6">
<table className="w-full text-sm"> <div className="p-4 flex flex-col md:flex-row gap-4">
<thead> <div className="w-full md:w-1/3">
<tr className="text-left border-b"> <Input
<th className="pb-4 font-medium">Karyawan</th> placeholder="Cari Nama / NIP..."
<th className="pb-4 font-medium">NIP & Jabatan</th> value={search}
<th className="pb-4 font-medium">Status</th> onChange={(e) => setSearch(e.target.value)}
<th className="pb-4 text-right font-medium">Aksi</th> />
</tr> </div>
</thead> <div className="w-full md:w-1/4">
<tbody> <Select
{employees.data.map((employee) => ( value={departmentId}
<tr key={employee.id} className="border-b"> onValueChange={(val) => setDepartmentId(val)}
<td className="py-4"> >
<div className="font-bold">{employee.user?.name}</div> <SelectTrigger>
<div className="text-xs text-muted-foreground">{employee.user?.email}</div> <SelectValue placeholder="Filter Departemen" />
</td> </SelectTrigger>
<td className="py-4"> <SelectContent>
<div>{employee.nip}</div> <SelectItem value="all">Semua Departemen</SelectItem>
<Badge variant="outline">{employee.position}</Badge> {departments.map((dept) => (
</td> <SelectItem key={dept.id} value={String(dept.id)}>
<td className="py-4"> {dept.name}
<Badge variant={employee.status === 'PKWTT' ? 'default' : 'secondary'}> </SelectItem>
{employee.status} ))}
</Badge> </SelectContent>
</td> </Select>
<td className="py-4 text-right"> </div>
<Button variant="ghost" asChild> </div>
<Link href={`/admin/employees/${employee.id}/edit`}>Edit</Link>
</Button> <CardContent className="pt-0">
<Button variant="ghost" className="text-red-600" asChild> <div className="relative w-full overflow-auto">
<Link href={`/admin/employees/${employee.id}`} method="delete" as="button">Hapus</Link> <table className="w-full text-sm text-left">
</Button> <thead className="bg-zinc-50/50">
</td> <tr className="border-b text-muted-foreground">
<th className="h-10 px-4 font-medium">Karyawan</th>
<th className="h-10 px-4 font-medium">Posisi & Dept</th>
<th className="h-10 px-4 font-medium">Status</th>
<th className="h-10 px-4 font-medium">Bergabung</th>
<th className="h-10 px-4 text-right font-medium">Aksi</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {employees.data.length > 0 ? (
employees.data.map((employee) => (
<tr key={employee.id} className="border-b hover:bg-zinc-50">
<td className="p-4">
<div className="font-bold">{employee.user?.name}</div>
<div className="text-xs text-muted-foreground">{employee.user?.email}</div>
<div className="text-xs text-zinc-400 mt-1">NIP: {employee.nip}</div>
</td>
<td className="p-4">
<div className="font-medium">{employee.position?.name || '-'}</div>
<Badge variant="outline" className="mt-1 font-normal text-xs">
{employee.department?.name || '-'}
</Badge>
</td>
<td className="p-4">
<Badge className={employee.status === 'PKWTT' ? 'bg-green-600' : 'bg-orange-500'}>
{employee.status}
</Badge>
</td>
<td className="p-4 text-muted-foreground">
{new Date(employee.join_date).toLocaleDateString('id-ID')}
</td>
<td className="p-4 text-right space-x-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/employees/${employee.id}/edit`}>Edit</Link>
</Button>
<Link
href={`/admin/employees/${employee.id}`}
method="delete"
as="button"
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 text-red-600 hover:bg-red-50"
>
Hapus
</Link>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
Tidak ada data ditemukan.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -0,0 +1,68 @@
import { Head, useForm, Link } from '@inertiajs/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
export default function Create() {
const { data, setData, post, processing, errors } = useForm({
name: '',
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
post('/admin/positions');
};
return (
<AppLayout>
<Head title="Tambah Jabatan" />
<div className="p-4 md:p-8 w-full">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Tambah Jabatan</h2>
<p className="text-muted-foreground">Buat posisi atau level baru.</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin/positions">Kembali</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Form Jabatan</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<form onSubmit={submit} className="space-y-6">
<div className="max-w-xl space-y-2">
<Label>Nama Jabatan <span className="text-red-500">*</span></Label>
<Input
placeholder="Contoh: Senior Backend Developer"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
</div>
<div className="flex justify-start gap-4 pt-4">
<Button type="submit" disabled={processing}>
Simpan Jabatan
</Button>
<Button type="button" variant="ghost" asChild>
<Link href="/admin/positions">Batal</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,76 @@
import { Head, useForm, Link } from '@inertiajs/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
interface Position {
id: number;
name: string;
}
interface PageProps {
position: Position;
}
export default function Edit({ position }: PageProps) {
const { data, setData, put, processing, errors } = useForm({
name: position.name,
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
put(`/admin/positions/${position.id}`);
};
return (
<AppLayout>
<Head title="Edit Jabatan" />
<div className="p-4 md:p-8 w-full">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Edit Jabatan</h2>
<p className="text-muted-foreground">Perbarui nama jabatan.</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin/positions">Kembali</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Form Edit Jabatan</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<form onSubmit={submit} className="space-y-6">
<div className="max-w-xl space-y-2">
<Label>Nama Jabatan</Label>
<Input
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
</div>
<div className="flex justify-start gap-4 pt-4">
<Button type="submit" disabled={processing}>
Simpan Perubahan
</Button>
<Button type="button" variant="ghost" asChild>
<Link href="/admin/positions">Batal</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,132 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Head, Link, usePage } from '@inertiajs/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import AppLayout from '@/layouts/app-layout';
interface Position {
id: number;
name: string;
employees_count: number;
}
interface PageProps {
positions: Position[];
[key: string]: unknown;
}
interface SharedData {
flash: {
success?: string;
error?: string;
};
}
export default function Index({ positions }: PageProps) {
const { flash } = usePage<any>().props as SharedData;
return (
<AppLayout>
<Head title="Manajemen Jabatan" />
<div className="p-4 md:p-8 w-full space-y-4">
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Data Jabatan</h2>
<p className="text-muted-foreground">Kelola level dan posisi pekerjaan.</p>
</div>
<Button asChild>
<Link href="/admin/positions/create">+ Tambah Jabatan</Link>
</Button>
</div>
<Card>
<CardHeader className="pb-4">
<CardTitle>Daftar Jabatan</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<table className="w-full text-sm text-left">
<thead className="text-muted-foreground bg-zinc-50/50">
<tr className="border-b">
<th className="h-12 px-4 font-medium">Nama Jabatan</th>
<th className="h-12 px-4 font-medium text-center">Total Karyawan</th>
<th className="h-12 px-4 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody>
{positions.length > 0 ? (
positions.map((pos) => (
<tr key={pos.id} className="border-b hover:bg-zinc-50">
<td className="p-4 font-bold text-gray-900">
{pos.name}
</td>
<td className="p-4 text-center">
<span className={`inline-flex items-center justify-center px-2.5 py-0.5 rounded-full text-xs font-medium ${pos.employees_count > 0 ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'}`}>
{pos.employees_count}
</span>
</td>
<td className="p-4 text-right space-x-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/positions/${pos.id}/edit`}>Edit</Link>
</Button>
{pos.employees_count > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} className="cursor-not-allowed inline-block">
<Button variant="ghost" size="sm" disabled className="text-red-300 opacity-50">Hapus</Button>
</span>
</TooltipTrigger>
<TooltipContent className="bg-red-50 text-red-700 border-red-200">
<p>Tidak bisa dihapus: Digunakan oleh {pos.employees_count} karyawan.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href={`/admin/positions/${pos.id}`}
method="delete"
as="button"
onClick={(e) => { if (!confirm('Hapus jabatan ini?')) e.preventDefault(); }}
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 text-red-600 hover:bg-red-50"
>
Hapus
</Link>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={3} className="p-8 text-center text-muted-foreground">
Belum ada data jabatan.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -1,36 +1,161 @@
import { Head } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
import { dashboard } from '@/routes'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { BreadcrumbItem } from '@/types'; import { Users, Building2, Briefcase, UserPlus } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
PieChart, Pie, Cell, Legend
} from 'recharts';
const breadcrumbs: BreadcrumbItem[] = [ interface DashboardProps {
{ stats: {
title: 'Dashboard', total_employees: number;
href: dashboard().url, total_departments: number;
}, total_positions: number;
]; };
genderData: any[];
deptData: any[];
latestEmployees: any[];
}
export default function Dashboard() { export default function Dashboard({ stats, genderData, deptData, latestEmployees }: DashboardProps) {
return ( return (
<AppLayout breadcrumbs={breadcrumbs}> <AppLayout breadcrumbs={[{ title: 'Dashboard', href: '/dashboard' }]}>
<Head title="Dashboard" /> <Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3"> <div className="flex flex-1 flex-col gap-4 p-4 md:p-8 pt-0">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> {/* CARD STATISTIK */}
</div> <div className="grid gap-4 md:grid-cols-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"> <Card>
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
</div> <CardTitle className="text-sm font-medium">Total Karyawan</CardTitle>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"> <Users className="h-4 w-4 text-muted-foreground" />
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> </CardHeader>
</div> <CardContent>
<div className="text-2xl font-bold">{stats.total_employees}</div>
<p className="text-xs text-muted-foreground">Pegawai aktif saat ini</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Departemen</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_departments}</div>
<p className="text-xs text-muted-foreground">Divisi terdaftar</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Jabatan</CardTitle>
<Briefcase className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_positions}</div>
<p className="text-xs text-muted-foreground">Posisi pekerjaan</p>
</CardContent>
</Card>
</div> </div>
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Grafik Batang */}
<Card className="col-span-4">
<CardHeader>
<CardTitle>Karyawan per Departemen</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={deptData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<RechartsTooltip
cursor={{ fill: 'transparent' }}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
/>
<Bar dataKey="employees" fill="#0f172a" radius={[4, 4, 0, 0]} barSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Grafik Donat*/}
<Card className="col-span-3">
<CardHeader>
<CardTitle>Komposisi Gender</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={genderData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{genderData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<RechartsTooltip />
<Legend verticalAlign="bottom" height={36}/>
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div> </div>
{/* TABEL KARYAWAN TERBARU */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Karyawan Terbaru Bergabung</CardTitle>
<Link href="/admin/employees" className="text-sm text-blue-600 hover:underline">
Lihat Semua
</Link>
</CardHeader>
<CardContent>
<div className="space-y-8">
{latestEmployees.map((emp) => (
<div key={emp.id} className="flex items-center">
<div className="h-9 w-9 rounded-full bg-slate-100 flex items-center justify-center border">
<UserPlus className="h-5 w-5 text-slate-500" />
</div>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">{emp.user.name}</p>
<p className="text-xs text-muted-foreground">{emp.position.name} {emp.department.name}</p>
</div>
<div className="ml-auto font-medium text-xs text-muted-foreground">
{new Date(emp.join_date).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
); );
} }

View File

@ -1,6 +1,9 @@
<?php <?php
use App\Http\Controllers\Admin\DepartmentController;
use App\Http\Controllers\Admin\EmployeeController; use App\Http\Controllers\Admin\EmployeeController;
use App\Http\Controllers\Admin\PositionController;
use App\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
@ -11,11 +14,13 @@
]); ]);
})->name('home'); })->name('home');
Route::get('dashboard', function () { Route::get('dashboard', [DashboardController::class, 'index'])
return Inertia::render('dashboard'); ->middleware(['auth', 'verified'])
})->middleware(['auth', 'verified'])->name('dashboard'); ->name('dashboard');
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () { Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('departments', DepartmentController::class);
Route::resource('positions', PositionController::class);
Route::resource('employees', EmployeeController::class); Route::resource('employees', EmployeeController::class);
}); });
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';