feat: add lecturer and student management pages with CRUD functionality

- Implemented LecturerEdit and LecturerIndex components for managing lecturers.
- Created StudentCreate and StudentEdit components for adding and editing students.
- Developed StudentIndex component for listing students with search and pagination.
- Added department and user types for better type safety.
- Updated routes for lecturers and students, including reset password functionality.
- Removed AppSidebar from dashboard for a cleaner layout.
This commit is contained in:
Yoga Pangestu 2026-08-21 14:14:01 +07:00
parent 32f700ea84
commit 2885de2c20
49 changed files with 3732 additions and 89 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum StudentStatus: string
{
case Active = 'active';
case OnLeave = 'on_leave';
case Graduated = 'graduated';
case DroppedOut = 'dropped_out';
public function label(): string
{
return match ($this) {
self::Active => 'Aktif',
self::OnLeave => 'Cuti',
self::Graduated => 'Lulus',
self::DroppedOut => 'Drop Out',
};
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\DepartmentRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Department;
use App\Services\Admin\Master\DepartmentService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class DepartmentController extends Controller
{
public function __construct(
private readonly DepartmentService $service
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/master/departments/index', [
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
]);
}
public function store(DepartmentRequest $request): RedirectResponse
{
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Jurusan berhasil ditambahkan.']);
return to_route('admin.master.departments.index');
}
public function update(DepartmentRequest $request, Department $department): RedirectResponse
{
$this->service->update($department, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Jurusan berhasil diperbarui.']);
return to_route('admin.master.departments.index');
}
public function destroy(Department $department): RedirectResponse
{
$this->service->delete($department);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Jurusan berhasil dihapus.']);
return back();
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Admin\Users;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Users\AdministratorRequest;
use App\Models\User;
use App\Services\Admin\Users\AdministratorService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class AdministratorController extends Controller
{
public function __construct(
protected AdministratorService $service,
) {}
public function index(Request $request): Response
{
return Inertia::render('admin/users/administrators/index', [
'administrators' => $this->service->getPaginated($request->only(['search', 'per_page'])),
'filters' => $request->only(['search', 'per_page']),
]);
}
public function create(): Response
{
return Inertia::render('admin/users/administrators/create', [
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']),
]);
}
public function store(AdministratorRequest $request): RedirectResponse
{
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Administrator berhasil ditambahkan.']);
return to_route('admin.users.administrators.index');
}
public function edit(User $user): Response
{
$user->load(['profile', 'roles']);
return Inertia::render('admin/users/administrators/edit', [
'user' => $user,
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']),
]);
}
public function update(AdministratorRequest $request, User $user): RedirectResponse
{
$this->service->update($user, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Administrator berhasil diperbarui.']);
return to_route('admin.users.administrators.index');
}
public function destroy(User $user): RedirectResponse
{
$this->service->delete($user);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Administrator berhasil dihapus.'])->back();
}
public function resetPassword(User $user): RedirectResponse
{
$this->service->resetPassword($user);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kata sandi berhasil direset.'])->back();
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Admin\Users;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Users\LecturerRequest;
use App\Models\User;
use App\Services\Admin\Users\DepartmentService;
use App\Services\Admin\Users\LecturerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class LecturerController extends Controller
{
public function __construct(
protected LecturerService $service,
protected DepartmentService $departmentService,
) {}
public function index(Request $request): Response
{
return Inertia::render('admin/users/lecturers/index', [
'lecturers' => $this->service->getPaginated($request->only(['search', 'per_page'])),
'filters' => $request->only(['search', 'per_page']),
]);
}
public function create(): Response
{
return Inertia::render('admin/users/lecturers/create', [
'departments' => $this->departmentService->getAll(),
]);
}
public function store(LecturerRequest $request): RedirectResponse
{
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Dosen berhasil ditambahkan.']);
return to_route('admin.users.lecturers.index');
}
public function edit(User $user): Response
{
$user->load(['profile', 'lecturer.department']);
return Inertia::render('admin/users/lecturers/edit', [
'user' => $user,
'departments' => $this->departmentService->getAll(),
]);
}
public function update(LecturerRequest $request, User $user): RedirectResponse
{
$this->service->update($user, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Dosen berhasil diperbarui.']);
return to_route('admin.users.lecturers.index');
}
public function destroy(User $user): RedirectResponse
{
$this->service->delete($user);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Dosen berhasil dihapus.'])->back();
}
public function resetPassword(User $user): RedirectResponse
{
$this->service->resetPassword($user);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Kata sandi berhasil direset.'])->back();
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Admin\Users;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Users\StudentRequest;
use App\Models\User;
use App\Services\Admin\Users\DepartmentService;
use App\Services\Admin\Users\LecturerService;
use App\Services\Admin\Users\StudentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class StudentController extends Controller
{
public function __construct(
protected StudentService $service,
protected DepartmentService $departmentService,
protected LecturerService $lecturerService,
) {}
public function index(Request $request): Response
{
return Inertia::render('admin/users/students/index', [
'students' => $this->service->getPaginated($request->only(['search', 'per_page'])),
'filters' => $request->only(['search', 'per_page']),
]);
}
public function create(): Response
{
return Inertia::render('admin/users/students/create', [
'departments' => $this->departmentService->getAll(),
'lecturers' => $this->lecturerService->getAllForSelect(),
]);
}
public function store(StudentRequest $request): RedirectResponse
{
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil ditambahkan.']);
return to_route('admin.users.students.index');
}
public function edit(User $user): Response
{
$user->load(['profile', 'student.department', 'student.advisor.user.profile']);
return Inertia::render('admin/users/students/edit', [
'user' => $user,
'departments' => $this->departmentService->getAll(),
'lecturers' => $this->lecturerService->getAllForSelect(),
]);
}
public function update(StudentRequest $request, User $user): RedirectResponse
{
$this->service->update($user, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil diperbarui.']);
return to_route('admin.users.students.index');
}
public function destroy(User $user): RedirectResponse
{
$this->service->delete($user);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Mahasiswa berhasil dihapus.'])->back();
}
public function resetPassword(User $user): RedirectResponse
{
$this->service->resetPassword($user);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Password berhasil direset.'])->back();
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Admin\Master;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class DepartmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => [
'required',
'string',
'max:10',
Rule::unique('departments')->ignore($this->route('department')),
],
'name' => ['required', 'string', 'max:100'],
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])],
];
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\Admin\Users;
use App\Enums\Gender;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdministratorRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$userId = $this->route('user')?->id;
return [
'username' => [
'required',
'string',
'max:20',
'min:5',
'alpha_dash',
$userId === null
? Rule::unique('users')
: Rule::unique('users')->ignore($userId),
],
'email' => [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique('users')
: Rule::unique('users')->ignore($userId),
],
'role' => ['required', 'string', Rule::in(['staff-admin', 'staff-keuangan'])],
// Profile
'full_name' => ['required', 'string', 'max:150'],
'phone_number' => ['required', 'string', 'max:20'],
'address' => ['required', 'string'],
'gender' => ['required', 'string', Rule::in(array_values(Gender::cases()))],
'birth_date' => ['required', 'date'],
'birth_place' => ['required', 'string', 'max:100'],
];
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Requests\Admin\Users;
use App\Enums\Gender;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LecturerRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$userId = $this->route('user')?->id;
return [
'username' => [
'required',
'string',
'max:20',
'min:5',
'alpha_dash',
$userId === null
? Rule::unique('users')
: Rule::unique('users')->ignore($userId),
],
'email' => [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique('users')
: Rule::unique('users')->ignore($userId),
],
// Profile
'full_name' => ['required', 'string', 'max:150'],
'phone_number' => ['required', 'string', 'max:20'],
'address' => ['required', 'string'],
'gender' => ['required', 'string', Rule::in(array_values(Gender::cases()))],
'birth_date' => ['required', 'date'],
'birth_place' => ['required', 'string', 'max:100'],
// Lecturer fields
'lecturer_number' => [
'required',
'string',
'max:20',
Rule::unique('lecturers')->ignore($userId, 'user_id'),
],
'department_id' => [
'required',
'integer',
Rule::exists('departments', 'id'),
],
];
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Http\Requests\Admin\Users;
use App\Enums\Gender;
use App\Enums\StudentStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StudentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$userId = $this->route('user')?->id;
return [
'username' => [
'required',
'string',
'max:20',
'min:5',
'alpha_dash',
$userId === null
? Rule::unique('users')
: Rule::unique('users')->ignore($userId),
],
'email' => [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique('users')
: Rule::unique('users')->ignore($userId),
],
// Profile
'full_name' => ['required', 'string', 'max:150'],
'phone_number' => ['required', 'string', 'max:20'],
'address' => ['required', 'string'],
'gender' => ['required', 'string', Rule::in(array_values(Gender::cases()))],
'birth_date' => ['required', 'date'],
'birth_place' => ['required', 'string', 'max:100'],
// Student fields
'student_number' => [
'required',
'string',
'max:10',
Rule::unique('students')->ignore($userId, 'user_id'),
],
'department_id' => [
'required',
'integer',
Rule::exists('departments', 'id'),
],
'enrollment_year' => [
'required',
'integer',
'digits:4',
],
'academic_advisor_id' => [
'required',
'integer',
Rule::exists('lecturers', 'id'),
],
'status' => [
'nullable',
'string',
Rule::in(array_values(StudentStatus::cases())),
],
];
}
}

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

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class Department extends Model
{
use HasFactory, SoftDeletes;
public function lecturers(): HasMany
{
return $this->hasMany(Lecturer::class);
}
public function students(): HasMany
{
return $this->hasMany(Student::class);
}
public function leaderships(): HasMany
{
return $this->hasMany(DepartmentLeadership::class);
}
public function currentLeader(): HasOne
{
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class DepartmentLeadership extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'started_at' => 'date',
'ended_at' => 'date',
];
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function lecturer(): BelongsTo
{
return $this->belongsTo(Lecturer::class);
}
}

36
app/Models/Lecturer.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class Lecturer extends Model
{
use HasFactory, SoftDeletes;
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function advisees(): HasMany
{
return $this->hasMany(Student::class, 'academic_advisor_id');
}
public function leaderships(): HasMany
{
return $this->hasMany(DepartmentLeadership::class);
}
}

38
app/Models/Student.php Normal file
View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Enums\StudentStatus;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class Student extends Model
{
use HasFactory, SoftDeletes;
protected function casts(): array
{
return [
'status' => StudentStatus::class,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function academicAdvisor(): BelongsTo
{
return $this->belongsTo(Lecturer::class, 'academic_advisor_id');
}
}

View File

@ -32,13 +32,21 @@ protected function casts(): array
protected function fullName(): Attribute protected function fullName(): Attribute
{ {
return Attribute::get(function () { return Attribute::get(fn () => $this->profile?->full_name ?? $this->username);
return $this->profile?->full_name ?? $this->username;
});
} }
public function profile(): HasOne public function profile(): HasOne
{ {
return $this->hasOne(UserProfile::class); return $this->hasOne(UserProfile::class);
} }
public function student(): HasOne
{
return $this->hasOne(Student::class);
}
public function lecturer(): HasOne
{
return $this->hasOne(Lecturer::class);
}
} }

View File

@ -0,0 +1,38 @@
<?php
namespace App\Services\Admin\Master;
use App\Models\Department;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class DepartmentService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Department::query()
->select(['id', 'code', 'name', 'degree_level'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function create(array $data): Department
{
return Department::create($data);
}
public function update(Department $department, array $data): Department
{
$department->code = $data['code'];
$department->name = $data['name'];
$department->degree_level = $data['degree_level'] ?? null;
$department->update();
return $department;
}
public function delete(Department $department): bool
{
return $department->delete();
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services\Admin\Users;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class AdministratorService
{
public function getPaginated(array $filters = []): LengthAwarePaginator
{
return User::with(['profile', 'roles'])
->whereHas('roles', fn($q) => $q->whereIn('name', ['staff-admin', 'staff-keuangan']))
->when($filters['search'] ?? null, fn($q, $search) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn($q) => $q->where('full_name', 'like', "%{$search}%"));
}))
->latest()
->paginate($filters['per_page'] ?? 25);
}
public function create(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'username' => $data['username'],
'email' => $data['email'],
'password' => Hash::make(config('app.default_password')),
]);
$user->assignRole($data['role']);
$user->profile()->create([
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'],
'address' => $data['address'],
'gender' => $data['gender'],
'birth_date' => $data['birth_date'],
'birth_place' => $data['birth_place'],
]);
return $user;
});
}
public function update(User $user, array $data): User
{
DB::transaction(function () use ($user, $data) {
$user->update([
'username' => $data['username'],
'email' => $data['email'],
]);
$user->syncRoles($data['role']);
$user->profile()->updateOrCreate([], [
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'],
'address' => $data['address'],
'gender' => $data['gender'],
'birth_date' => $data['birth_date'],
'birth_place' => $data['birth_place'],
]);
});
return $user->fresh(['profile', 'roles']);
}
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$user->profile()->delete();
$user->delete();
});
}
public function resetPassword(User $user): void
{
$user->update([
'password' => Hash::make(config('app.default_password')),
]);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Services\Admin\Users;
use App\Models\Department;
use Illuminate\Support\Collection;
class DepartmentService
{
public function getAll(): Collection
{
return Department::select(['id', 'name'])->get();
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Services\Admin\Users;
use App\Models\Lecturer;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class LecturerService
{
public function getAllForSelect(): Collection
{
return Lecturer::with('user.profile')->get();
}
public function getPaginated(array $filters = []): LengthAwarePaginator
{
return User::with(['profile', 'lecturer.department'])
->whereHas('roles', fn($q) => $q->where('name', 'dosen'))
->when($filters['search'] ?? null, fn($q, $search) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('lecturer', fn($q) => $q->where('lecturer_number', 'like', "%{$search}%"));
}))
->latest()
->paginate($filters['per_page'] ?? 25);
}
public function create(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'username' => $data['username'],
'email' => $data['email'],
'password' => Hash::make(config('app.default_password')),
]);
$user->assignRole('dosen');
$user->profile()->create([
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'],
'address' => $data['address'],
'gender' => $data['gender'],
'birth_date' => $data['birth_date'],
'birth_place' => $data['birth_place'],
]);
$user->lecturer()->create([
'lecturer_number' => $data['lecturer_number'],
'department_id' => $data['department_id'],
]);
return $user;
});
}
public function update(User $user, array $data): User
{
DB::transaction(function () use ($user, $data) {
$user->update([
'username' => $data['username'],
'email' => $data['email'],
]);
$user->profile()->updateOrCreate([], [
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'],
'address' => $data['address'],
'gender' => $data['gender'],
'birth_date' => $data['birth_date'],
'birth_place' => $data['birth_place'],
]);
$user->lecturer()->updateOrCreate([], [
'lecturer_number' => $data['lecturer_number'],
'department_id' => $data['department_id'],
]);
});
return $user->fresh(['profile', 'lecturer.department', 'roles']);
}
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$user->lecturer()->delete();
$user->profile()->delete();
$user->delete();
});
}
public function resetPassword(User $user): void
{
$user->update([
'password' => Hash::make(config('app.default_password')),
]);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Services\Admin\Users;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class StudentService
{
public function getPaginated(array $filters = []): LengthAwarePaginator
{
return User::with(['profile', 'student.department'])
->whereHas('roles', fn($q) => $q->where('name', 'mahasiswa'))
->when($filters['search'] ?? null, fn($q, $search) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('student', fn($q) => $q->where('student_number', 'like', "%{$search}%"));
}))
->latest()
->paginate($filters['per_page'] ?? 25);
}
public function create(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'username' => $data['username'],
'email' => $data['email'],
'password' => Hash::make(config('app.default_password')),
]);
$user->assignRole('mahasiswa');
$user->profile()->create([
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'],
'address' => $data['address'],
'gender' => $data['gender'],
'birth_date' => $data['birth_date'],
'birth_place' => $data['birth_place'],
]);
$user->student()->create([
'student_number' => $data['student_number'],
'department_id' => $data['department_id'],
'enrollment_year' => $data['enrollment_year'],
'academic_advisor_id' => $data['academic_advisor_id'],
'status' => 'active',
]);
return $user;
});
}
public function update(User $user, array $data): User
{
DB::transaction(function () use ($user, $data) {
$user->update([
'username' => $data['username'],
'email' => $data['email'],
]);
$user->profile()->updateOrCreate([], [
'full_name' => $data['full_name'],
'phone_number' => $data['phone_number'],
'address' => $data['address'],
'gender' => $data['gender'],
'birth_date' => $data['birth_date'],
'birth_place' => $data['birth_place'],
]);
$user->student()->updateOrCreate([], [
'student_number' => $data['student_number'],
'department_id' => $data['department_id'],
'enrollment_year' => $data['enrollment_year'],
'academic_advisor_id' => $data['academic_advisor_id'],
'status' => $data['status'] ?? null,
]);
});
return $user->fresh(['profile', 'student.department', 'roles']);
}
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$user->student()->delete();
$user->profile()->delete();
$user->delete();
});
}
public function resetPassword(User $user): void
{
$user->update([
'password' => Hash::make(config('app.default_password')),
]);
}
}

View File

@ -123,4 +123,5 @@
'store' => env('APP_MAINTENANCE_STORE', 'database'), 'store' => env('APP_MAINTENANCE_STORE', 'database'),
], ],
'default_password' => env('APP_DEFAULT_PASSWORD', 'Minimal8@'),
]; ];

View File

@ -13,11 +13,11 @@ public function up(): void
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('full_name', 150); $table->string('full_name', 150);
$table->string('phone_number', 20)->nullable(); $table->string('phone_number', 20);
$table->text('address')->nullable(); $table->text('address');
$table->enum('gender', array_values(Gender::cases()))->nullable(); $table->enum('gender', array_values(Gender::cases()));
$table->string('birth_place', 100)->nullable(); $table->date('birth_date');
$table->date('birth_date')->nullable(); $table->string('birth_place', 100);
$table->timestamps(); $table->timestamps();
$table->softDeletes(); $table->softDeletes();
}); });

View File

@ -0,0 +1,25 @@
<?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('departments', function (Blueprint $table) {
$table->id();
$table->string('code', 10)->unique();
$table->string('name', 100);
$table->string('degree_level', 10)->nullable()->default('S1');
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('departments');
}
};

View File

@ -0,0 +1,25 @@
<?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('lecturers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('lecturer_number', 10)->unique();
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('lecturers');
}
};

View File

@ -0,0 +1,25 @@
<?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('department_leaderships', function (Blueprint $table) {
$table->id();
$table->foreignId('department_id')->constrained()->cascadeOnDelete();
$table->foreignId('lecturer_id')->constrained()->cascadeOnDelete();
$table->date('started_at');
$table->date('ended_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('department_leaderships');
}
};

View File

@ -0,0 +1,29 @@
<?php
use App\Enums\StudentStatus;
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('students', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('student_number', 10)->unique();
$table->foreignId('department_id')->constrained()->cascadeOnDelete();
$table->integer('enrollment_year');
$table->foreignId('academic_advisor_id')->nullable()->constrained('lecturers')->nullOnDelete();
$table->enum('status', array_values(StudentStatus::cases()))->nullable()->default(StudentStatus::Active);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('students');
}
};

View File

@ -13,8 +13,10 @@ public function run(): void
{ {
$this->call([ $this->call([
RolePermissionSeeder::class, RolePermissionSeeder::class,
DepartmentSeeder::class,
UserSeeder::class, UserSeeder::class,
AcademicTermSeeder::class, AcademicTermSeeder::class,
DepartmentLeadershipSeeder::class,
]); ]);
} }
} }

View File

@ -0,0 +1,39 @@
<?php
namespace Database\Seeders;
use App\Models\DepartmentLeadership;
use Illuminate\Database\Seeder;
class DepartmentLeadershipSeeder extends Seeder
{
public function run(): void
{
DepartmentLeadership::insert([
[
'department_id' => 1,
'lecturer_id' => 1,
'started_at' => '2024-01-10',
'ended_at' => null,
'created_at' => now(),
'updated_at' => now(),
],
[
'department_id' => 2,
'lecturer_id' => 2,
'started_at' => '2024-01-10',
'ended_at' => null,
'created_at' => now(),
'updated_at' => now(),
],
[
'department_id' => 3,
'lecturer_id' => 3,
'started_at' => '2023-06-01',
'ended_at' => null,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Database\Seeders;
use App\Models\Department;
use Illuminate\Database\Seeder;
class DepartmentSeeder extends Seeder
{
public function run(): void
{
Department::insert([
[
'code' => 'SI',
'name' => 'Sistem Informasi',
'degree_level' => 'S1',
'created_at' => now(),
'updated_at' => now(),
],
[
'code' => 'TI',
'name' => 'Teknik Industri',
'degree_level' => 'S1',
'created_at' => now(),
'updated_at' => now(),
],
[
'code' => 'BD',
'name' => 'Bisnis Digital',
'degree_level' => 'S1',
'created_at' => now(),
'updated_at' => now(),
],
]);
}
}

View File

@ -2,19 +2,67 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\StudentStatus;
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;
class UserSeeder extends Seeder class UserSeeder extends Seeder
{ {
/**
* Run the database seeds.
*/
public function run(): void public function run(): void
{ {
$users = [ $users = [
[
'username' => 'dwi',
'email' => 'dwi@lecturer.itmpwk.ac.id',
'roles' => ['dosen'],
'profile' => [
'full_name' => 'Dwi Saputra',
'phone_number' => '081234567801',
'address' => 'Jl. Kampus No. 1, Purwakarta',
'gender' => 'male',
'birth_place' => 'Bandung',
'birth_date' => '1985-03-15',
],
'lecturer' => [
'lecturer_number' => '0012345601',
'department_id' => 1,
],
],
[
'username' => 'rina',
'email' => 'rina@lecturer.itmpwk.ac.id',
'roles' => ['dosen'],
'profile' => [
'full_name' => 'Rina Marlina',
'phone_number' => '081234567802',
'address' => 'Jl. Kampus No. 1, Purwakarta',
'gender' => 'female',
'birth_place' => 'Jakarta',
'birth_date' => '1987-07-20',
],
'lecturer' => [
'lecturer_number' => '0012345602',
'department_id' => 2,
],
],
[
'username' => 'budi',
'email' => 'budi@lecturer.itmpwk.ac.id',
'roles' => ['dosen'],
'profile' => [
'full_name' => 'Budi Hartono',
'phone_number' => '081234567803',
'address' => 'Jl. Kampus No. 1, Purwakarta',
'gender' => 'male',
'birth_place' => 'Surabaya',
'birth_date' => '1983-11-10',
],
'lecturer' => [
'lecturer_number' => '0012345603',
'department_id' => 3,
],
],
[ [
'username' => 'pangestu', 'username' => 'pangestu',
'email' => 'pangestu@student.itmpwk.ac.id', 'email' => 'pangestu@student.itmpwk.ac.id',
@ -27,6 +75,13 @@ public function run(): void
'birth_place' => 'Subang', 'birth_place' => 'Subang',
'birth_date' => '2005-03-13', 'birth_date' => '2005-03-13',
], ],
'student' => [
'student_number' => '23010001',
'department_id' => 1,
'enrollment_year' => 2023,
'academic_advisor_id' => 1,
'status' => StudentStatus::Active,
],
], ],
[ [
'username' => 'doni', 'username' => 'doni',
@ -40,6 +95,13 @@ public function run(): void
'birth_place' => 'Purwakarta', 'birth_place' => 'Purwakarta',
'birth_date' => '2004-10-30', 'birth_date' => '2004-10-30',
], ],
'student' => [
'student_number' => '23020002',
'department_id' => 2,
'enrollment_year' => 2023,
'academic_advisor_id' => 2,
'status' => StudentStatus::Active,
],
], ],
[ [
'username' => 'asep', 'username' => 'asep',
@ -53,6 +115,13 @@ public function run(): void
'birth_place' => 'Purwakarta', 'birth_place' => 'Purwakarta',
'birth_date' => '2005-09-24', 'birth_date' => '2005-09-24',
], ],
'student' => [
'student_number' => '24010003',
'department_id' => 1,
'enrollment_year' => 2024,
'academic_advisor_id' => 1,
'status' => StudentStatus::Active,
],
], ],
[ [
'username' => 'komoala', 'username' => 'komoala',
@ -66,19 +135,41 @@ public function run(): void
'birth_place' => 'Purwakarta', 'birth_place' => 'Purwakarta',
'birth_date' => '2005-05-04', 'birth_date' => '2005-05-04',
], ],
'student' => [
'student_number' => '24020004',
'department_id' => 2,
'enrollment_year' => 2024,
'academic_advisor_id' => 2,
'status' => StudentStatus::Active,
],
], ],
]; ];
foreach ($users as $userData) { foreach ($users as $userData) {
$roles = $userData['roles']; $roles = $userData['roles'];
$profile = $userData['profile']; $profile = $userData['profile'];
unset($userData['profile'], $userData['roles']); unset($userData['profile'], $userData['roles']);
$userData['password'] = Hash::make('Minimal8@'); $student = $userData['student'] ?? null;
$lecturer = $userData['lecturer'] ?? null;
unset($userData['profile'], $userData['student'], $userData['lecturer']);
$userData['password'] = Hash::make(config('app.default_password'));
$user = User::create($userData); $user = User::create($userData);
$user->profile()->create($profile); $user->profile()->create($profile);
$user->assignRole($roles); $user->assignRole($roles);
if ($lecturer) {
$user->lecturer()->create($lecturer);
}
if ($student) {
$user->student()->create($student);
}
} }
} }
} }

View File

@ -1,82 +1,117 @@
import { usePage } from "@inertiajs/react"; import { usePage } from '@inertiajs/react';
import type { Icon } from "@tabler/icons-react"; import type { Icon } from '@tabler/icons-react';
import { import {
IconGridDots, IconGridDots,
IconHelp, IconHelp,
IconMessageDots IconMessageDots,
} from "@tabler/icons-react"; IconUsers,
import * as React from "react"; } from '@tabler/icons-react';
import * as React from 'react';
import AppLogoIcon from "@/components/app-logo-icon"; import AppLogoIcon from '@/components/app-logo-icon';
import { NavMain, type NavGroup, type NavItem } from "@/components/nav-main"; import { NavMain, type NavGroup, type NavItem } from '@/components/nav-main';
import { NavSecondary } from "@/components/nav-secondary"; import { NavSecondary } from '@/components/nav-secondary';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from '@/components/ui/sidebar';
import { index as academicTerm } from "@/routes/admin/master/academic-terms"; import { index as academicTerm } from '@/routes/admin/master/academic-terms';
import { Calendar } from "lucide-react"; import { index as departmentsRoute } from '@/routes/admin/master/departments';
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
import { index as studentsRoute } from '@/routes/admin/users/students';
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
import { Building2, Calendar, GraduationCap, User, Users } from 'lucide-react';
const data: { navMain: (NavGroup | NavItem)[]; navSecondary: { title: string; url: string; icon: Icon }[] } = { const data: {
navMain: [ navMain: (NavGroup | NavItem)[];
{ navSecondary: { title: string; url: string; icon: Icon }[];
name: "Dasbor", } = {
url: "#", navMain: [
icon: IconGridDots,
},
{
label: "Master",
items: [
{ {
name: "Periode Akademik", name: 'Dasbor',
url: academicTerm.url(), url: '#',
icon: Calendar, icon: IconGridDots,
}, },
], {
}, label: 'Master',
], items: [
navSecondary: [ {
{ name: 'Periode Akademik',
title: "Kritik dan Saran", url: academicTerm.url(),
url: "#", icon: Calendar,
icon: IconMessageDots, },
}, {
{ name: 'Jurusan',
title: "Bantuan", url: departmentsRoute.url(),
url: "#", icon: Building2,
icon: IconHelp, },
}, ],
], },
} {
label: 'Pengguna',
items: [
{
name: 'Administrator',
url: administratorsRoute.url(),
icon: User,
},
{
name: 'Dosen',
url: lecturersRoute.url(),
icon: Users,
},
{
name: 'Mahasiswa',
url: studentsRoute.url(),
icon: GraduationCap,
},
],
},
],
navSecondary: [
{
title: 'Kritik dan Saran',
url: '#',
icon: IconMessageDots,
},
{
title: 'Bantuan',
url: '#',
icon: IconHelp,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { name } = usePage().props; const { name } = usePage().props;
return ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
asChild asChild
className="data-[slot=sidebar-menu-button]:p-1.5!" className="data-[slot=sidebar-menu-button]:p-1.5!"
> >
<a href="#"> <a href="#">
<AppLogoIcon className="size-5! rounded-sm object-cover" /> <AppLogoIcon className="size-5! rounded-sm object-cover" />
<span className="text-base font-semibold">{name}</span> <span className="text-base font-semibold">
</a> {name}
</SidebarMenuButton> </span>
</SidebarMenuItem> </a>
</SidebarMenu> </SidebarMenuButton>
</SidebarHeader> </SidebarMenuItem>
<SidebarContent> </SidebarMenu>
<NavMain items={data.navMain} /> </SidebarHeader>
<NavSecondary items={data.navSecondary} className="mt-auto" /> <SidebarContent>
</SidebarContent> <NavMain items={data.navMain} />
</Sidebar> <NavSecondary items={data.navSecondary} className="mt-auto" />
) </SidebarContent>
</Sidebar>
);
} }

View File

@ -0,0 +1,87 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { Department } from '@/types/department';
export type { Department } from '@/types/department';
type CreateColumnsParams = {
handleEdit: (department: Department) => void;
handleDeleteClick: (department: Department) => void;
};
export function createDepartmentColumns(
params: CreateColumnsParams,
): ColumnDef<Department>[] {
const { handleEdit, handleDeleteClick } = params;
return [
{
accessorKey: 'code',
header: () => <span>Kode</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('code') as string}
</span>
),
},
{
accessorKey: 'name',
header: () => <span>Nama</span>,
cell: ({ row }) => <span>{row.getValue('name') as string}</span>,
},
{
accessorKey: 'degree_level',
header: () => <span className="block text-center">Jenjang</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const degreeLevel = row.getValue('degree_level') as
string | null;
return (
<div className="flex justify-center">
{degreeLevel ? (
<Badge variant="secondary">{degreeLevel}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
);
},
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const department = row.original;
return (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(department),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(department),
},
]}
/>
);
},
},
];
}

View File

@ -0,0 +1,298 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as departmentIndex,
destroy,
store,
update,
} from '@/routes/admin/master/departments';
import { DegreeLevels } from '@/types/department';
import type { Department } from '@/types/department';
import { createDepartmentColumns } from './columns';
type Props = {
departments: {
data: Department[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
highlight?: number;
};
export default function DepartmentIndex({ departments, highlight }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [deleting, setDeleting] = useState<Department | null>(null);
const pagination: PaginationState = {
current_page: departments.current_page,
last_page: departments.last_page,
per_page: departments.per_page,
total: departments.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => departmentIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createDepartmentColumns({
handleEdit: (department) => setEditing(department),
handleDeleteClick: (department) => setDeleting(department),
});
return (
<>
<Head title="Jurusan" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Jurusan"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan jurusan dari notifikasi.
<button
onClick={() => {
router.get(
departmentIndex.url(),
{},
{
replace: true,
preserveState: true,
},
);
}}
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
Tampilkan semua
</button>
</p>
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
}
/>
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
/>
<DataTable
columns={columns}
data={departments.data}
searchKey="name"
searchPlaceholder="Cari jurusan..."
emptyText="Belum ada data jurusan."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Jurusan"
description={(department) =>
`Apakah Anda yakin ingin menghapus jurusan "${department.name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Jurusan"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="code">
Kode <span className="text-destructive">*</span>
</Label>
<Input id="code" name="code" placeholder="Contoh: SI" />
<InputError message={errors.code} />
</div>
<div className="grid gap-2">
<Label htmlFor="name">
Nama <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama jurusan"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>Jenjang</Label>
<input type="hidden" name="degree_level" />
<Select name="degree_level">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jenjang" />
</SelectTrigger>
<SelectContent>
{DegreeLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.degree_level} />
</div>
</div>
)}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Department | null;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Jurusan"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-code">
Kode <span className="text-destructive">*</span>
</Label>
<Input
id="edit-code"
name="code"
placeholder="Contoh: SI"
defaultValue={editing.code}
/>
<InputError message={errors.code} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama <span className="text-destructive">*</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama jurusan"
defaultValue={editing.name}
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>Jenjang</Label>
<input
type="hidden"
name="degree_level"
defaultValue={editing.degree_level ?? ''}
/>
<Select
name="degree_level"
defaultValue={editing.degree_level ?? undefined}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jenjang" />
</SelectTrigger>
<SelectContent>
{DegreeLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.degree_level} />
</div>
</div>
)
}
</FormDialog>
);
}

View File

@ -0,0 +1,92 @@
import { RowActions } from '@/components/row-actions';
import type { ColumnDef } from '@tanstack/react-table';
import { Key, Pencil, Trash2 } from 'lucide-react';
export type Administrator = {
id: number;
username: string;
email: string;
profile: { full_name: string; phone_number: string; gender: string } | null;
roles: { id: number; name: string }[];
};
type CreateColumnsParams = {
handleEdit: (admin: Administrator) => void;
handleDeleteClick: (admin: Administrator) => void;
handleResetPassword: (admin: Administrator) => void;
};
export function createAdministratorColumns(
params: CreateColumnsParams,
): ColumnDef<Administrator>[] {
const { handleEdit, handleDeleteClick, handleResetPassword } = params;
return [
{
accessorKey: 'profile.full_name',
header: () => <span>Nama Lengkap</span>,
cell: ({ row }) => row.original.profile?.full_name ?? '-',
},
{
id: 'account',
header: () => <span>Akun</span>,
cell: ({ row }) => (
<div className="flex flex-col">
<span>{row.original.username}</span>
<span className="text-sm text-muted-foreground">{row.original.email}</span>
</div>
),
},
{
id: 'role',
header: () => <span>Role</span>,
cell: ({ row }) => row.original.roles?.[0]?.name ?? '-',
},
{
accessorKey: 'profile.phone_number',
header: () => <span>Nomor Telepon</span>,
cell: ({ row }) => row.original.profile?.phone_number ?? '-',
},
{
accessorKey: 'profile.gender',
header: () => <span>Jenis Kelamin</span>,
cell: ({ row }) => {
const gender = row.original.profile?.gender;
return gender === 'male' ? 'Laki-laki' : gender === 'female' ? 'Perempuan' : '-';
},
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const admin = row.original;
return (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(admin),
},
{
label: 'Reset Kata Sandi',
icon: <Key className="h-4 w-4" />,
onClick: () => handleResetPassword(admin),
},
{
label: 'Hapus',
icon: <Trash2 className="h-4 w-4 text-destructive" />,
onClick: () => handleDeleteClick(admin),
},
]}
/>
);
},
},
];
}

View File

@ -0,0 +1,160 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { index, store } from '@/routes/admin/users/administrators';
import { Form, Head } from '@inertiajs/react';
import { format } from 'date-fns';
import { useState } from 'react';
type Role = { id: number; name: string };
type Props = {
roles: Role[];
};
export default function AdministratorCreate({ roles }: Props) {
const [gender, setGender] = useState('');
const [birthDate, setBirthDate] = useState<Date | undefined>();
const [role, setRole] = useState('');
return (
<>
<Head title="Tambah Administrator" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Tambah Administrator"
actions={
<Button variant="outline" asChild>
<a href={index.url()}>Kembali</a>
</Button>
}
/>
<Form action={store()} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Informasi Akun</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-red-500">*</span>
</Label>
<Input id="username" name="username" placeholder="Masukkan username" />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input id="email" name="email" type="email" placeholder="Masukkan email" />
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label>
Role <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="role" value={role} />
<Combobox value={role} onValueChange={(v) => setRole(v ?? '')}>
<ComboboxInput placeholder="Pilih role" className="w-full" />
<ComboboxContent>
<ComboboxList>
{roles.map((r) => (
<ComboboxItem key={r.id} value={r.name}>
{r.name}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.role} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil Pribadi</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span>
</Label>
<Input id="full_name" name="full_name" placeholder="Masukkan nama lengkap" />
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span>
</Label>
<PhoneInput id="phone_number" name="phone_number" placeholder="08xx xxxx xxxx" />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Jenis Kelamin <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="gender" value={gender} />
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
<div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" />
<Label htmlFor="male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" />
<Label htmlFor="female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>
Tempat Lahir <span className="text-red-500">*</span>
</Label>
<Input name="birth_place" placeholder="Masukkan tempat lahir" />
<InputError message={errors.birth_place} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Lahir <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} />
<DatePicker value={birthDate} onChange={setBirthDate} />
<InputError message={errors.birth_date} />
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat <span className="text-red-500">*</span>
</Label>
<Textarea id="address" name="address" placeholder="Masukkan alamat" />
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,171 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { index, update } from '@/routes/admin/users/administrators';
import { Form, Head } from '@inertiajs/react';
import { format } from 'date-fns';
import { useState } from 'react';
type Role = { id: number; name: string };
type User = {
id: number;
username: string;
email: string;
profile: { full_name: string; phone_number: string; address: string; gender: string; birth_date: string; birth_place: string } | null;
roles: { id: number; name: string }[];
};
type Props = {
user: User;
roles: Role[];
};
export default function AdministratorEdit({ user, roles }: Props) {
const [gender, setGender] = useState(user.profile?.gender ?? '');
const [birthDate, setBirthDate] = useState<Date | undefined>(
user.profile?.birth_date ? new Date(user.profile.birth_date) : undefined
);
const [role, setRole] = useState(user.roles?.[0]?.name ?? '');
return (
<>
<Head title="Edit Administrator" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Edit Administrator"
actions={
<Button variant="outline" asChild>
<a href={index.url()}>Kembali</a>
</Button>
}
/>
<Form action={update(user.id)} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Informasi Akun</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-red-500">*</span>
</Label>
<Input id="username" name="username" defaultValue={user.username} />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input id="email" name="email" type="email" defaultValue={user.email} />
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label>
Role <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="role" value={role} />
<Combobox value={role} onValueChange={(v) => setRole(v ?? '')}>
<ComboboxInput placeholder="Pilih role" className="w-full" />
<ComboboxContent>
<ComboboxList>
{roles.map((r) => (
<ComboboxItem key={r.id} value={r.name}>
{r.name}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.role} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil Pribadi</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span>
</Label>
<Input id="full_name" name="full_name" defaultValue={user.profile?.full_name ?? ''} />
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span>
</Label>
<PhoneInput id="phone_number" name="phone_number" value={user.profile?.phone_number ?? ''} />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Jenis Kelamin <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="gender" value={gender} />
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
<div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" />
<Label htmlFor="male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" />
<Label htmlFor="female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>
Tempat Lahir <span className="text-red-500">*</span>
</Label>
<Input name="birth_place" defaultValue={user.profile?.birth_place ?? ''} />
<InputError message={errors.birth_place} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Lahir <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} />
<DatePicker value={birthDate} onChange={setBirthDate} />
<InputError message={errors.birth_date} />
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat <span className="text-red-500">*</span>
</Label>
<Textarea id="address" name="address" defaultValue={user.profile?.address ?? ''} />
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,144 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as administratorsIndex,
create,
destroy,
edit,
reset_password
} from '@/routes/admin/users/administrators';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { Administrator } from './columns';
import { createAdministratorColumns } from './columns';
type Props = {
administrators: {
data: Administrator[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
};
export default function AdministratorIndex({ administrators }: Props) {
const [deleting, setDeleting] = useState<Administrator | null>(null);
const [resetting, setResetting] = useState<Administrator | null>(null);
const pagination: PaginationState = {
current_page: administrators.current_page,
last_page: administrators.last_page,
per_page: administrators.per_page,
total: administrators.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => administratorsIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
function handleResetPassword() {
if (!resetting) {
return;
}
router.patch(reset_password.url(resetting.id), {}, {
onSuccess: () => setResetting(null),
});
}
const columns = createAdministratorColumns({
handleEdit: (admin) => {
router.get(edit.url(admin.id));
},
handleDeleteClick: (admin) => setDeleting(admin),
handleResetPassword: (admin) => setResetting(admin),
});
return (
<>
<Head title="Administrator" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Administrator"
actions={
<Button asChild>
<a href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<DataTable
columns={columns}
data={administrators.data}
searchKey="name"
searchPlaceholder="Cari administrator..."
emptyText="Belum ada data administrator."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Administrator"
description={(admin) =>
`Apakah Anda yakin ingin menghapus administrator "${admin.profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
<ConfirmDialog
open={resetting !== null}
onOpenChange={(open) => {
if (!open) {
setResetting(null);
}
}}
title="Reset Kata Sandi"
description={
resetting
? `Apakah Anda yakin ingin mereset kata sandi administrator "${resetting.profile?.full_name}" ke kata sandi default?`
: ''
}
confirmLabel="Reset"
variant="default"
onConfirm={handleResetPassword}
/>
</div>
</>
);
}

View File

@ -0,0 +1,84 @@
import { RowActions } from '@/components/row-actions';
import type { ColumnDef } from '@tanstack/react-table';
import { Key, Pencil, Trash2 } from 'lucide-react';
export type Lecturer = {
id: number;
username: string;
email: string;
profile: { full_name: string } | null;
lecturer: { lecturer_number: string; department: { name: string } | null } | null;
};
type CreateColumnsParams = {
handleEdit: (lecturer: Lecturer) => void;
handleDeleteClick: (lecturer: Lecturer) => void;
handleResetPassword: (lecturer: Lecturer) => void;
};
export function createLecturerColumns(
params: CreateColumnsParams,
): ColumnDef<Lecturer>[] {
const { handleEdit, handleDeleteClick, handleResetPassword } = params;
return [
{
accessorKey: 'lecturer.lecturer_number',
header: () => <span>NIDN</span>,
cell: ({ row }) => row.original.lecturer?.lecturer_number ?? '-',
},
{
accessorKey: 'profile.full_name',
header: () => <span>Nama Lengkap</span>,
cell: ({ row }) => row.original.profile?.full_name ?? '-',
},
{
id: 'account',
header: () => <span>Akun</span>,
cell: ({ row }) => (
<div className="flex flex-col">
<span>{row.original.username}</span>
<span className="text-sm text-muted-foreground">{row.original.email}</span>
</div>
),
},
{
accessorKey: 'lecturer.department.name',
header: () => <span>Jurusan</span>,
cell: ({ row }) => row.original.lecturer?.department?.name ?? '-',
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const lecturer = row.original;
return (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(lecturer),
},
{
label: 'Reset Password',
icon: <Key className="h-4 w-4" />,
onClick: () => handleResetPassword(lecturer),
},
{
label: 'Hapus',
icon: <Trash2 className="h-4 w-4 text-destructive" />,
onClick: () => handleDeleteClick(lecturer),
},
]}
/>
);
},
},
];
}

View File

@ -0,0 +1,174 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
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 { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index, store } from '@/routes/admin/users/lecturers';
import { Form, Head } from '@inertiajs/react';
import { format } from 'date-fns';
import { useState } from 'react';
type Department = { id: number; name: string };
type Props = {
departments: Department[];
};
export default function LecturerCreate({ departments }: Props) {
const [gender, setGender] = useState('');
const [birthDate, setBirthDate] = useState<Date | undefined>();
return (
<>
<Head title="Tambah Dosen" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Tambah Dosen"
actions={
<Button variant="outline" asChild>
<a href={index.url()}>Kembali</a>
</Button>
}
/>
<Form action={store()} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Informasi Akun</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-red-500">*</span>
</Label>
<Input id="username" name="username" placeholder="Masukkan username" />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input id="email" name="email" type="email" placeholder="Masukkan email" />
<InputError message={errors.email} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil Pribadi</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span>
</Label>
<Input id="full_name" name="full_name" placeholder="Masukkan nama lengkap" />
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span>
</Label>
<PhoneInput id="phone_number" name="phone_number" placeholder="08xx xxxx xxxx" />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Jenis Kelamin <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="gender" value={gender} />
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
<div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" />
<Label htmlFor="male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" />
<Label htmlFor="female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>
Tempat Lahir <span className="text-red-500">*</span>
</Label>
<Input name="birth_place" placeholder="Masukkan tempat lahir" />
<InputError message={errors.birth_place} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Lahir <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} />
<DatePicker value={birthDate} onChange={setBirthDate} />
<InputError message={errors.birth_date} />
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat <span className="text-red-500">*</span>
</Label>
<Textarea id="address" name="address" placeholder="Masukkan alamat" />
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informasi Dosen</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="lecturer_number">
NIDN <span className="text-red-500">*</span>
</Label>
<Input id="lecturer_number" name="lecturer_number" placeholder="Masukkan NIDN" />
<InputError message={errors.lecturer_number} />
</div>
<div className="grid gap-2">
<Label>
Jurusan <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="department_id" />
<Select name="department_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jurusan" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.department_id} />
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,184 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
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 { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index, update } from '@/routes/admin/users/lecturers';
import { Form, Head } from '@inertiajs/react';
import { format } from 'date-fns';
import { useState } from 'react';
type Department = { id: number; name: string };
type User = {
id: number;
username: string;
email: string;
profile: { full_name: string; phone_number: string; address: string; gender: string; birth_date: string; birth_place: string } | null;
lecturer: { lecturer_number: string; department_id: number } | null;
};
type Props = {
user: User;
departments: Department[];
};
export default function LecturerEdit({ user, departments }: Props) {
const [gender, setGender] = useState(user.profile?.gender ?? '');
const [birthDate, setBirthDate] = useState<Date | undefined>(
user.profile?.birth_date ? new Date(user.profile.birth_date) : undefined
);
return (
<>
<Head title="Edit Dosen" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Edit Dosen"
actions={
<Button variant="outline" asChild>
<a href={index.url()}>Kembali</a>
</Button>
}
/>
<Form action={update(user.id)} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Informasi Akun</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-red-500">*</span>
</Label>
<Input id="username" name="username" defaultValue={user.username} />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input id="email" name="email" type="email" defaultValue={user.email} />
<InputError message={errors.email} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil Pribadi</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span>
</Label>
<Input id="full_name" name="full_name" defaultValue={user.profile?.full_name ?? ''} />
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span>
</Label>
<PhoneInput id="phone_number" name="phone_number" value={user.profile?.phone_number ?? ''} />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Jenis Kelamin <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="gender" value={gender} />
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
<div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" />
<Label htmlFor="male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" />
<Label htmlFor="female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>
Tempat Lahir <span className="text-red-500">*</span>
</Label>
<Input name="birth_place" defaultValue={user.profile?.birth_place ?? ''} />
<InputError message={errors.birth_place} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Lahir <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} />
<DatePicker value={birthDate} onChange={setBirthDate} />
<InputError message={errors.birth_date} />
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat <span className="text-red-500">*</span>
</Label>
<Textarea id="address" name="address" defaultValue={user.profile?.address ?? ''} />
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informasi Dosen</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="lecturer_number">
NIDN <span className="text-red-500">*</span>
</Label>
<Input id="lecturer_number" name="lecturer_number" defaultValue={user.lecturer?.lecturer_number ?? ''} />
<InputError message={errors.lecturer_number} />
</div>
<div className="grid gap-2">
<Label>
Jurusan <span className="text-red-500">*</span>
</Label>
<Select name="department_id" defaultValue={user.lecturer?.department_id ? String(user.lecturer.department_id) : undefined}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jurusan" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.department_id} />
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,144 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as lecturersIndex,
create,
edit,
destroy,
reset_password,
} from '@/routes/admin/users/lecturers';
import type { Lecturer } from './columns';
import { createLecturerColumns } from './columns';
type Props = {
lecturers: {
data: Lecturer[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
};
export default function LecturerIndex({ lecturers }: Props) {
const [deleting, setDeleting] = useState<Lecturer | null>(null);
const [resetting, setResetting] = useState<Lecturer | null>(null);
const pagination: PaginationState = {
current_page: lecturers.current_page,
last_page: lecturers.last_page,
per_page: lecturers.per_page,
total: lecturers.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => lecturersIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
function handleResetPassword() {
if (!resetting) {
return;
}
router.patch(reset_password.url(resetting.id), {}, {
onSuccess: () => setResetting(null),
});
}
const columns = createLecturerColumns({
handleEdit: (lecturer) => {
router.get(edit.url(lecturer.id));
},
handleDeleteClick: (lecturer) => setDeleting(lecturer),
handleResetPassword: (lecturer) => setResetting(lecturer),
});
return (
<>
<Head title="Dosen" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Dosen"
actions={
<Button asChild>
<a href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<DataTable
columns={columns}
data={lecturers.data}
searchKey="name"
searchPlaceholder="Cari dosen..."
emptyText="Belum ada data dosen."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Dosen"
description={(lecturer) =>
`Apakah Anda yakin ingin menghapus dosen "${lecturer.profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
<ConfirmDialog
open={resetting !== null}
onOpenChange={(open) => {
if (!open) {
setResetting(null);
}
}}
title="Reset Kata Sandi"
description={
resetting
? `Apakah Anda yakin ingin mereset kata sandi dosen "${resetting.profile?.full_name}" ke kata sandi default?`
: ''
}
confirmLabel="Reset"
variant="default"
onConfirm={handleResetPassword}
/>
</div>
</>
);
}

View File

@ -0,0 +1,94 @@
import { RowActions } from '@/components/row-actions';
import type { ColumnDef } from '@tanstack/react-table';
import { Key, Pencil, Trash2 } from 'lucide-react';
export type Student = {
id: number;
username: string;
email: string;
profile: { full_name: string } | null;
student: { student_number: string; department: { name: string } | null; enrollment_year: number; status: string | null } | null;
};
type CreateColumnsParams = {
handleEdit: (student: Student) => void;
handleDeleteClick: (student: Student) => void;
handleResetPassword: (student: Student) => void;
};
export function createStudentColumns(
params: CreateColumnsParams,
): ColumnDef<Student>[] {
const { handleEdit, handleDeleteClick, handleResetPassword } = params;
return [
{
accessorKey: 'student.student_number',
header: () => <span>NIM</span>,
cell: ({ row }) => row.original.student?.student_number ?? '-',
},
{
accessorKey: 'profile.full_name',
header: () => <span>Nama Lengkap</span>,
cell: ({ row }) => row.original.profile?.full_name ?? '-',
},
{
id: 'account',
header: () => <span>Akun</span>,
cell: ({ row }) => (
<div className="flex flex-col">
<span>{row.original.username}</span>
<span className="text-sm text-muted-foreground">{row.original.email}</span>
</div>
),
},
{
accessorKey: 'student.department.name',
header: () => <span>Jurusan</span>,
cell: ({ row }) => row.original.student?.department?.name ?? '-',
},
{
accessorKey: 'student.enrollment_year',
header: () => <span>Angkatan</span>,
cell: ({ row }) => row.original.student?.enrollment_year ?? '-',
},
{
accessorKey: 'student.status',
header: () => <span>Status</span>,
cell: ({ row }) => row.original.student?.status ?? '-',
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const student = row.original;
return (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(student),
},
{
label: 'Reset Password',
icon: <Key className="h-4 w-4" />,
onClick: () => handleResetPassword(student),
},
{
label: 'Hapus',
icon: <Trash2 className="h-4 w-4 text-destructive" />,
onClick: () => handleDeleteClick(student),
},
]}
/>
);
},
},
];
}

View File

@ -0,0 +1,202 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
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 { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index, store } from '@/routes/admin/users/students';
import { Form, Head } from '@inertiajs/react';
import { format } from 'date-fns';
import { useState } from 'react';
type Department = { id: number; name: string };
type Lecturer = { id: number; lecturer_number: string; user: { profile: { full_name: string } | null } | null };
type Props = {
departments: Department[];
lecturers: Lecturer[];
};
export default function StudentCreate({ departments, lecturers }: Props) {
const [gender, setGender] = useState('');
const [birthDate, setBirthDate] = useState<Date | undefined>();
return (
<>
<Head title="Tambah Mahasiswa" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Tambah Mahasiswa"
actions={
<Button variant="outline" asChild>
<a href={index.url()}>Kembali</a>
</Button>
}
/>
<Form action={store()} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Informasi Akun</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-red-500">*</span>
</Label>
<Input id="username" name="username" placeholder="Masukkan username" />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input id="email" name="email" type="email" placeholder="Masukkan email" />
<InputError message={errors.email} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil Pribadi</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span>
</Label>
<Input id="full_name" name="full_name" placeholder="Masukkan nama lengkap" />
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span>
</Label>
<PhoneInput id="phone_number" name="phone_number" placeholder="08xx xxxx xxxx" />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Jenis Kelamin <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="gender" value={gender} />
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
<div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" />
<Label htmlFor="male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" />
<Label htmlFor="female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>
Tempat Lahir <span className="text-red-500">*</span>
</Label>
<Input name="birth_place" placeholder="Masukkan tempat lahir" />
<InputError message={errors.birth_place} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Lahir <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} />
<DatePicker value={birthDate} onChange={setBirthDate} />
<InputError message={errors.birth_date} />
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat <span className="text-red-500">*</span>
</Label>
<Textarea id="address" name="address" placeholder="Masukkan alamat" />
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informasi Mahasiswa</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="student_number">
NIM <span className="text-red-500">*</span>
</Label>
<Input id="student_number" name="student_number" placeholder="Masukkan NIM" />
<InputError message={errors.student_number} />
</div>
<div className="grid gap-2">
<Label>
Jurusan <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="department_id" />
<Select name="department_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jurusan" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.department_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="enrollment_year">
Tahun Masuk <span className="text-red-500">*</span>
</Label>
<Input id="enrollment_year" name="enrollment_year" type="number" placeholder="Contoh: 2024" />
<InputError message={errors.enrollment_year} />
</div>
<div className="grid gap-2">
<Label>
Dosen Wali <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="academic_advisor_id" />
<Select name="academic_advisor_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih dosen wali" />
</SelectTrigger>
<SelectContent>
{lecturers.map((lect) => (
<SelectItem key={lect.id} value={String(lect.id)}>
{lect.user?.profile?.full_name ?? 'N/A'} - {lect.lecturer_number}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_advisor_id} />
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,228 @@
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
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 { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index, update } from '@/routes/admin/users/students';
import { Form, Head } from '@inertiajs/react';
import { format } from 'date-fns';
import { useState } from 'react';
type Department = { id: number; name: string };
type Lecturer = { id: number; lecturer_number: string; user: { profile: { full_name: string } | null } | null };
type User = {
id: number;
username: string;
email: string;
profile: { full_name: string; phone_number: string; address: string; gender: string; birth_date: string; birth_place: string } | null;
student: { student_number: string; department_id: number; enrollment_year: number; academic_advisor_id: number; status: string | null } | null;
};
type Props = {
user: User;
departments: Department[];
lecturers: Lecturer[];
};
export default function StudentEdit({ user, departments, lecturers }: Props) {
const [gender, setGender] = useState(user.profile?.gender ?? '');
const [birthDate, setBirthDate] = useState<Date | undefined>(
user.profile?.birth_date ? new Date(user.profile.birth_date) : undefined
);
return (
<>
<Head title="Edit Mahasiswa" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Edit Mahasiswa"
actions={
<Button variant="outline" asChild>
<a href={index.url()}>Kembali</a>
</Button>
}
/>
<Form action={update(user.id)} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Informasi Akun</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-red-500">*</span>
</Label>
<Input id="username" name="username" defaultValue={user.username} />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input id="email" name="email" type="email" defaultValue={user.email} />
<InputError message={errors.email} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil Pribadi</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-red-500">*</span>
</Label>
<Input id="full_name" name="full_name" defaultValue={user.profile?.full_name ?? ''} />
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor Telepon <span className="text-red-500">*</span>
</Label>
<PhoneInput id="phone_number" name="phone_number" value={user.profile?.phone_number ?? ''} />
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2">
<Label>
Jenis Kelamin <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="gender" value={gender} />
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
<div className="flex items-center gap-2">
<RadioGroupItem value="male" id="male" />
<Label htmlFor="male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="female" id="female" />
<Label htmlFor="female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>
Tempat Lahir <span className="text-red-500">*</span>
</Label>
<Input name="birth_place" defaultValue={user.profile?.birth_place ?? ''} />
<InputError message={errors.birth_place} />
</div>
<div className="grid gap-2">
<Label>
Tanggal Lahir <span className="text-red-500">*</span>
</Label>
<input type="hidden" name="birth_date" value={birthDate ? format(birthDate, 'yyyy-MM-dd') : ''} />
<DatePicker value={birthDate} onChange={setBirthDate} />
<InputError message={errors.birth_date} />
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
Alamat <span className="text-red-500">*</span>
</Label>
<Textarea id="address" name="address" defaultValue={user.profile?.address ?? ''} />
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informasi Mahasiswa</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="student_number">
NIM <span className="text-red-500">*</span>
</Label>
<Input id="student_number" name="student_number" defaultValue={user.student?.student_number ?? ''} />
<InputError message={errors.student_number} />
</div>
<div className="grid gap-2">
<Label>
Jurusan <span className="text-red-500">*</span>
</Label>
<Select name="department_id" defaultValue={user.student?.department_id ? String(user.student.department_id) : undefined}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jurusan" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.department_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="enrollment_year">
Tahun Masuk <span className="text-red-500">*</span>
</Label>
<Input id="enrollment_year" name="enrollment_year" type="number" defaultValue={user.student?.enrollment_year ?? ''} />
<InputError message={errors.enrollment_year} />
</div>
<div className="grid gap-2">
<Label>
Dosen Wali <span className="text-red-500">*</span>
</Label>
<Select name="academic_advisor_id" defaultValue={user.student?.academic_advisor_id ? String(user.student.academic_advisor_id) : undefined}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih dosen wali" />
</SelectTrigger>
<SelectContent>
{lecturers.map((lect) => (
<SelectItem key={lect.id} value={String(lect.id)}>
{lect.user?.profile?.full_name ?? 'N/A'} - {lect.lecturer_number}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.academic_advisor_id} />
</div>
<div className="grid gap-2">
<Label>
Status <span className="text-red-500">*</span>
</Label>
<Select name="status" defaultValue={user.student?.status ?? undefined}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="on_leave">Cuti</SelectItem>
<SelectItem value="graduated">Lulus</SelectItem>
<SelectItem value="dropped_out">Drop Out</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.status} />
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,143 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
create,
destroy,
edit,
reset_password,
index as studentsIndex,
} from '@/routes/admin/users/students';
import { createStudentColumns, type Student } from './columns';
type Props = {
students: {
data: Student[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
};
export default function StudentIndex({ students }: Props) {
const [deleting, setDeleting] = useState<Student | null>(null);
const [resetting, setResetting] = useState<Student | null>(null);
const pagination: PaginationState = {
current_page: students.current_page,
last_page: students.last_page,
per_page: students.per_page,
total: students.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => studentsIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
function handleResetPassword() {
if (!resetting) {
return;
}
router.patch(reset_password.url(resetting.id), {}, {
onSuccess: () => setResetting(null),
});
}
const columns = createStudentColumns({
handleEdit: (student) => {
router.get(edit.url(student.id));
},
handleDeleteClick: (student) => setDeleting(student),
handleResetPassword: (student) => setResetting(student),
});
return (
<>
<Head title="Mahasiswa" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Mahasiswa"
actions={
<Button asChild>
<a href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<DataTable
columns={columns}
data={students.data}
searchKey="name"
searchPlaceholder="Cari mahasiswa..."
emptyText="Belum ada data mahasiswa."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Mahasiswa"
description={(student) =>
`Apakah Anda yakin ingin menghapus mahasiswa "${student.profile?.full_name}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
<ConfirmDialog
open={resetting !== null}
onOpenChange={(open) => {
if (!open) {
setResetting(null);
}
}}
title="Reset Kata Sandi"
description={
resetting
? `Apakah Anda yakin ingin mereset kata sandi mahasiswa "${resetting.profile?.full_name}" ke kata sandi default?`
: ''
}
confirmLabel="Reset"
variant="default"
onConfirm={handleResetPassword}
/>
</div>
</>
);
}

View File

@ -1,4 +1,3 @@
import { AppSidebar } from "@/components/app-sidebar";
import { ChartAreaInteractive } from "@/components/chart-area-interactive"; import { ChartAreaInteractive } from "@/components/chart-area-interactive";
import { SectionCards } from "@/components/section-cards"; import { SectionCards } from "@/components/section-cards";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
@ -17,7 +16,6 @@ export default function Page() {
} as React.CSSProperties } as React.CSSProperties
} }
> >
<AppSidebar variant="inset" />
<SidebarInset> <SidebarInset>
<SiteHeader /> <SiteHeader />
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">

View File

@ -0,0 +1,12 @@
export const DegreeLevels = ['D3', 'D4', 'S1', 'S2', 'S3'] as const;
export type DegreeLevel = (typeof DegreeLevels)[number];
export type Department = {
id: number;
code: string;
name: string;
degree_level: DegreeLevel | null;
created_at: string;
updated_at: string;
};

View File

@ -2,3 +2,4 @@ export type * from './academic-term';
export type * from './auth'; export type * from './auth';
export type * from './navigation'; export type * from './navigation';
export type * from './ui'; export type * from './ui';
export type * from './user';

View File

@ -0,0 +1,76 @@
export enum Gender {
Male = 'male',
Female = 'female',
}
export const GenderLabels: Record<Gender, string> = {
[Gender.Male]: 'Laki-laki',
[Gender.Female]: 'Perempuan',
};
export enum StudentStatus {
Active = 'active',
OnLeave = 'on_leave',
Graduated = 'graduated',
DroppedOut = 'dropped_out',
}
export const StudentStatusLabels: Record<StudentStatus, string> = {
[StudentStatus.Active]: 'Aktif',
[StudentStatus.OnLeave]: 'Cuti',
[StudentStatus.Graduated]: 'Lulus',
[StudentStatus.DroppedOut]: 'Drop Out',
};
export type Department = {
id: number;
code: string;
name: string;
};
export type UserProfile = {
full_name: string;
phone_number: string | null;
address: string | null;
gender: Gender | null;
birth_date: string | null;
birth_place: string | null;
};
export type Lecturer = {
id: number;
lecturer_number: string;
department: Department | null;
user?: {
profile?: {
full_name: string;
} | null;
} | null;
};
export type Student = {
id: number;
student_number: string;
department: Department | null;
enrollment_year: number;
academic_advisor: Lecturer | null;
status: StudentStatus;
};
export type Role = {
id: number;
name: string;
};
export type AdminUser = {
id: number;
username: string;
email: string;
is_active: boolean;
roles: Role[];
profile: UserProfile | null;
lecturer: Lecturer | null;
student: Student | null;
created_at: string;
updated_at: string;
};

View File

@ -1,8 +1,26 @@
<?php <?php
use App\Http\Controllers\Admin\Master\AcademicTermController; use App\Http\Controllers\Admin\Master\AcademicTermController;
use App\Http\Controllers\Admin\Master\DepartmentController;
use App\Http\Controllers\Admin\Users\AdministratorController;
use App\Http\Controllers\Admin\Users\LecturerController;
use App\Http\Controllers\Admin\Users\StudentController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'verified'])->prefix('admin/master')->name('admin.master.')->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('academic-terms', AcademicTermController::class)->except(['create', 'edit', 'show']); Route::prefix('admin/master')->name('admin.master.')->group(function () {
Route::resource('academic-terms', AcademicTermController::class)->except(['create', 'edit', 'show']);
Route::resource('departments', DepartmentController::class)->except(['create', 'edit', 'show']);
});
Route::prefix('admin/users')->name('admin.users.')->group(function () {
Route::resource('lecturers', LecturerController::class)->except(['show'])->parameters(['lecturers' => 'user']);
Route::patch('lecturers/{user}/reset-password', [LecturerController::class, 'resetPassword'])->name('lecturers.reset_password');
Route::resource('students', StudentController::class)->except(['show'])->parameters(['students' => 'user']);
Route::patch('students/{user}/reset-password', [StudentController::class, 'resetPassword'])->name('students.reset_password');
Route::resource('administrators', AdministratorController::class)->except(['show'])->parameters(['administrators' => 'user']);
Route::patch('administrators/{user}/reset-password', [AdministratorController::class, 'resetPassword'])->name('administrators.reset_password');
});
}); });